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 001/125] 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 002/125] 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 003/125] 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 004/125] 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 005/125] 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 006/125] 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 007/125] 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 008/125] 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 009/125] 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 010/125] 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 012/125] 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 014/125] 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 015/125] 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 016/125] 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 017/125] 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 018/125] 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 019/125] 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 020/125] 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 021/125] 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 022/125] 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 023/125] 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 024/125] 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 025/125] 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 027/125] 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) From 6ff14c1bf537ec024dc354d20b167486179e859d Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Wed, 4 Feb 2026 04:39:18 -0300 Subject: [PATCH 028/125] chore(legal): @zhiquanchi 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 8a4c239de977e86e38dbaf5f6f87061b58b44d2f..ac4d0712cefc09c230db4c277e1537ba68e0b58d 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1175,6 +1175,14 @@ "created_at": "2026-02-02T19:27:08Z", "repoId": 987670088, "pullRequestNo": 2095 + }, + { + "name": "zhiquanchi", + "id": 29973289, + "comment_id": 3845838711, + "created_at": "2026-02-04T07:39:06Z", + "repoId": 987670088, + "pullRequestNo": 2112 } ] } \ No newline at end of file From 112fea822b66cbac3d295a3a2f86d8ac9e534060 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 4 Feb 2026 12:26:47 +0300 Subject: [PATCH 029/125] fix(ui): cursor mispositioned when pasting large blocks of text in textarea (#2113) --- internal/ui/model/ui.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 806ce0bcdf4bf0217f759aa97d361b1e60a824b7..28f1a7230308618628c1261c5a3b67fba7432d17 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -684,8 +684,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } case openEditorMsg: + var cmd tea.Cmd m.textarea.SetValue(msg.Text) m.textarea.MoveToEnd() + m.textarea, cmd = m.textarea.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } case uiutil.InfoMsg: m.status.SetInfoMsg(msg) ttl := msg.TTL From f53402d57eac0590d009611cf892c8957eab15e7 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 4 Feb 2026 12:58:55 +0300 Subject: [PATCH 030/125] fix(ui): context percentage updates (#2115) * fix(ui): context percentage updates When the agent is performing tasks, the context percentage in the header was not updating correctly. This commit fixes the issue by ensuring that the header always draws the context details. * fix(ui): always turn off compact mode when going to landing state --- internal/ui/model/header.go | 62 ++++++++++++++++++++++++++++--------- internal/ui/model/ui.go | 57 +++++++++++++++++----------------- 2 files changed, 75 insertions(+), 44 deletions(-) diff --git a/internal/ui/model/header.go b/internal/ui/model/header.go index e01a19143c20e0d3e2c6753b719c28092077ac91..5e704bf6ed8a5f69e224ceeca05d34ad59740789 100644 --- a/internal/ui/model/header.go +++ b/internal/ui/model/header.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" ) @@ -22,29 +23,58 @@ const ( rightPadding = 1 ) -// renderCompactHeader renders the compact header for the given session. -func renderCompactHeader( - com *common.Common, +type header struct { + // cached logo and compact logo + logo string + compactLogo string + + com *common.Common + width int + compact bool +} + +// newHeader creates a new header model. +func newHeader(com *common.Common) *header { + h := &header{ + com: com, + } + t := com.Styles + h.compactLogo = t.Header.Charm.Render("Charm™") + " " + + styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary) + " " + return h +} + +// drawHeader draws the header for the given session. +func (h *header) drawHeader( + scr uv.Screen, + area uv.Rectangle, session *session.Session, - lspClients *csync.Map[string, *lsp.Client], + compact bool, detailsOpen bool, width int, -) string { - if session == nil || session.ID == "" { - return "" +) { + t := h.com.Styles + if width != h.width || compact != h.compact { + h.logo = renderLogo(h.com.Styles, compact, width) } - t := com.Styles + h.width = width + h.compact = compact - var b strings.Builder + if !compact || session == nil || h.com.App == nil { + uv.NewStyledString(h.logo).Draw(scr, area) + return + } + + if session.ID == "" { + return + } - b.WriteString(t.Header.Charm.Render("Charm™")) - b.WriteString(" ") - b.WriteString(styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary)) - b.WriteString(" ") + var b strings.Builder + b.WriteString(h.compactLogo) availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags - details := renderHeaderDetails(com, session, lspClients, detailsOpen, availDetailWidth) + details := renderHeaderDetails(h.com, session, h.com.App.LSPClients, detailsOpen, availDetailWidth) remainingWidth := width - lipgloss.Width(b.String()) - @@ -61,7 +91,9 @@ func renderCompactHeader( b.WriteString(details) - return t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String()) + view := uv.NewStyledString( + t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())) + view.Draw(scr, area) } // renderHeaderDetails renders the details section of the header. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 28f1a7230308618628c1261c5a3b67fba7432d17..a77f55fb919cdb7c809d86e698c96265797da5b3 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -141,8 +141,7 @@ type UI struct { // isCanceling tracks whether the user has pressed escape once to cancel. isCanceling bool - // header is the last cached header logo - header string + header *header // sendProgressBar instructs the TUI to send progress bar updates to the // terminal. @@ -261,12 +260,15 @@ func New(com *common.Common) *UI { }, ) + header := newHeader(com) + ui := &UI{ com: com, dialog: dialog.NewOverlay(), keyMap: keyMap, textarea: ta, chat: ch, + header: header, completions: comp, attachments: attachments, todoSpinner: todoSpinner, @@ -325,6 +327,10 @@ func (m *UI) Init() tea.Cmd { // setState changes the UI state and focus. func (m *UI) setState(state uiState, focus uiFocusState) { + if state == uiLanding { + // Always turn off compact mode when going to landing + m.isCompact = false + } m.state = state m.focus = focus // Changing the state may change layout, so update it. @@ -1761,6 +1767,18 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { return tea.Batch(cmds...) } +// drawHeader draws the header section of the UI. +func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) { + m.header.drawHeader( + scr, + area, + m.session, + m.isCompact, + m.detailsOpen, + m.width, + ) +} + // Draw implements [uv.Drawable] and draws the UI model. func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { layout := m.generateLayout(area.Dx(), area.Dy()) @@ -1775,22 +1793,19 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { switch m.state { case uiOnboarding: - header := uv.NewStyledString(m.header) - header.Draw(scr, layout.header) + m.drawHeader(scr, layout.header) // NOTE: Onboarding flow will be rendered as dialogs below, but // positioned at the bottom left of the screen. case uiInitialize: - header := uv.NewStyledString(m.header) - header.Draw(scr, layout.header) + m.drawHeader(scr, layout.header) main := uv.NewStyledString(m.initializeView()) main.Draw(scr, layout.main) case uiLanding: - header := uv.NewStyledString(m.header) - header.Draw(scr, layout.header) + m.drawHeader(scr, layout.header) main := uv.NewStyledString(m.landingView()) main.Draw(scr, layout.main) @@ -1799,8 +1814,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { case uiChat: if m.isCompact { - header := uv.NewStyledString(m.header) - header.Draw(scr, layout.header) + m.drawHeader(scr, layout.header) } else { m.drawSidebar(scr, layout.sidebar) } @@ -2177,14 +2191,9 @@ func (m *UI) updateSize() { // Handle different app states switch m.state { - case uiOnboarding, uiInitialize, uiLanding: - m.renderHeader(false, m.layout.header.Dx()) - case uiChat: - if m.isCompact { - m.renderHeader(true, m.layout.header.Dx()) - } else { - m.renderSidebarLogo(m.layout.sidebar.Dx()) + if !m.isCompact { + m.cacheSidebarLogo(m.layout.sidebar.Dx()) } } } @@ -2590,18 +2599,8 @@ func (m *UI) renderEditorView(width int) string { ) } -// renderHeader renders and caches the header logo at the specified width. -func (m *UI) renderHeader(compact bool, width int) { - if compact && m.session != nil && m.com.App != nil { - m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width) - } else { - m.header = renderLogo(m.com.Styles, compact, width) - } -} - -// renderSidebarLogo renders and caches the sidebar logo at the specified -// width. -func (m *UI) renderSidebarLogo(width int) { +// cacheSidebarLogo renders and caches the sidebar logo at the specified width. +func (m *UI) cacheSidebarLogo(width int) { m.sidebarLogo = renderLogo(m.com.Styles, true, width) } From 3bda767c6700db1ead64af01471b3e7b868a569f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 4 Feb 2026 14:53:24 +0300 Subject: [PATCH 031/125] fix(ui): ensure we anchor the chat view to the bottom when toggling (#2117) an item at the bottom of the chat When toggling an item in the chat, if that item is at the bottom of the chat, we want to ensure that we stay anchored to the bottom. This prevents a gap from appearing at the bottom of the chat when toggling an item that is currently selected and at the bottom. --- internal/ui/list/list.go | 2 +- internal/ui/model/chat.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 33a5087c9ceae3f03bb2c8f78b2cc8089f87057c..c3693494881c0a600f3d519471835789ebd54530 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -79,7 +79,7 @@ func (l *List) Gap() int { func (l *List) AtBottom() bool { const margin = 2 - if len(l.items) == 0 { + if len(l.items) == 0 || l.offsetIdx >= len(l.items)-1 { return true } diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 723e97fb76c04d75922a5aec60d9afa970e41d97..8deb2c3992e249b7baa78ca693f88beaf44ae5d4 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -439,6 +439,9 @@ func (m *Chat) ToggleExpandedSelectedItem() { if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok { expandable.ToggleExpanded() m.list.ScrollToIndex(m.list.Selected()) + if m.list.AtBottom() { + m.list.ScrollToBottom() + } } } @@ -547,6 +550,9 @@ func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool { expandable.ToggleExpanded() } m.list.ScrollToIndex(m.list.Selected()) + if m.list.AtBottom() { + m.list.ScrollToBottom() + } return handled } From 15a729cbddc6aa087d4a8189829253924d5019f2 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 4 Feb 2026 15:20:44 +0300 Subject: [PATCH 032/125] fix(ui): only scroll to selected item if item collapsed --- internal/ui/model/chat.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 8deb2c3992e249b7baa78ca693f88beaf44ae5d4..00a17ecfc5042dd42f4d24682b135667d1345386 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -437,8 +437,9 @@ func (m *Chat) MessageItem(id string) chat.MessageItem { // ToggleExpandedSelectedItem expands the selected message item if it is expandable. func (m *Chat) ToggleExpandedSelectedItem() { if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok { - expandable.ToggleExpanded() - m.list.ScrollToIndex(m.list.Selected()) + if !expandable.ToggleExpanded() { + m.list.ScrollToIndex(m.list.Selected()) + } if m.list.AtBottom() { m.list.ScrollToBottom() } @@ -547,9 +548,10 @@ func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool { handled := clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y) // Toggle expansion if applicable. if expandable, ok := selectedItem.(chat.Expandable); ok { - expandable.ToggleExpanded() + if !expandable.ToggleExpanded() { + m.list.ScrollToIndex(m.list.Selected()) + } } - m.list.ScrollToIndex(m.list.Selected()) if m.list.AtBottom() { m.list.ScrollToBottom() } From 247d89e5c29d681d465edb1fc02174bf8a271a44 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 4 Feb 2026 09:25:57 -0300 Subject: [PATCH 033/125] ci: use OIDC for npm login (#2094) needs https://github.com/charmbracelet/meta/pull/274 Signed-off-by: Carlos Alexandro Becker --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 368a53af629553b1d34bc682f7f5e7b7f53be777..8c58e6bdf7bd1492665daf7b9ac966edec0da0d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,6 @@ jobs: fury_token: ${{ secrets.FURY_TOKEN }} nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }} nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }} - npm_token: ${{ secrets.NPM_TOKEN }} snapcraft_token: ${{ secrets.SNAPCRAFT_TOKEN }} aur_key: ${{ secrets.AUR_KEY }} macos_sign_p12: ${{ secrets.MACOS_SIGN_P12 }} From 142c854e52e50d48dd37497b6aad09bc222cba51 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 4 Feb 2026 14:09:50 -0300 Subject: [PATCH 035/125] fix: change hyper url (#2120) Signed-off-by: Carlos Alexandro Becker --- internal/agent/hyper/provider.go | 5 ++--- internal/agent/hyper/provider.json | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/agent/hyper/provider.go b/internal/agent/hyper/provider.go index 8ba3a538e4a97b4691dff4eb9aba46f83b523912..bba8542549827622baa0a47b40e39765e5dc9376 100644 --- a/internal/agent/hyper/provider.go +++ b/internal/agent/hyper/provider.go @@ -27,7 +27,7 @@ import ( "github.com/charmbracelet/crush/internal/event" ) -//go:generate wget -O provider.json https://console.charm.land/api/v1/provider +//go:generate wget -O provider.json https://hyper.charm.land/api/v1/provider //go:embed provider.json var embedded []byte @@ -61,8 +61,7 @@ const ( // Name is the default name of this meta provider. Name = "hyper" // defaultBaseURL is the default proxy URL. - // TODO: change this to production URL when ready. - defaultBaseURL = "https://console.charm.land" + defaultBaseURL = "https://hyper.charm.land" ) // BaseURL returns the base URL, which is either $HYPER_URL or the default. diff --git a/internal/agent/hyper/provider.json b/internal/agent/hyper/provider.json index d2d0fc0d6edbce4e4e87626bcd2f09af4c9c8f14..4f2cd461e46eeea6bb18739535a03003cb075f26 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-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 +{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://hyper.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 From 874c1ca0e8bf89533ce1fafcbe7c1dd37187e30b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 4 Feb 2026 15:12:54 -0300 Subject: [PATCH 036/125] chore: update ui/agents.md (#2122) * chore: update ui/agents.md it should always do io inside a tea.cmd Signed-off-by: Carlos Alexandro Becker * Apply suggestion from @caarlos0 --------- Signed-off-by: Carlos Alexandro Becker --- internal/ui/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/AGENTS.md b/internal/ui/AGENTS.md index 7fce65ce12d69d2d1be0268c9acbd45fd7605851..9bb2ceaf20da8b75df3a40390111b2a8be7f94c2 100644 --- a/internal/ui/AGENTS.md +++ b/internal/ui/AGENTS.md @@ -4,7 +4,7 @@ - Never use commands to send messages when you can directly mutate children or state. - Keep things simple; do not overcomplicate. - Create files if needed to separate logic; do not nest models. -- Always do IO in commands +- Never do IO or expensive work in `Update`; always use a `tea.Cmd`. - Never change the model state inside of a command use messages and than update the state in the main loop ## Architecture From d558340c1cf3f2c899698ef5c163cde78cf51992 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:22:42 -0300 Subject: [PATCH 037/125] chore(legal): @inquam 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 ac4d0712cefc09c230db4c277e1537ba68e0b58d..57bdae4b7b2676479f37efad425ae2234aad3d34 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1183,6 +1183,14 @@ "created_at": "2026-02-04T07:39:06Z", "repoId": 987670088, "pullRequestNo": 2112 + }, + { + "name": "inquam", + "id": 1265038, + "comment_id": 3849304908, + "created_at": "2026-02-04T19:22:33Z", + "repoId": 987670088, + "pullRequestNo": 2124 } ] } \ No newline at end of file From b28f4ced49e4ce5c76446a9ee3e3fcd90adac744 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 4 Feb 2026 17:07:11 -0300 Subject: [PATCH 038/125] fix(ui): completions popup gets too narrow on single item (#2125) Signed-off-by: Carlos Alexandro Becker --- internal/ui/completions/completions.go | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go index 66389e3b99c09123334c1685bd8e22e4d7354ed1..a23ba5bf181f00856082b17aed8ef1ba5a816e93 100644 --- a/internal/ui/completions/completions.go +++ b/internal/ui/completions/completions.go @@ -128,16 +128,7 @@ func (c *Completions) SetFiles(files []string) { c.list.SelectFirst() c.list.ScrollToSelected() - // recalculate width by using just the visible items - start, end := c.list.VisibleItemIndices() - width := 0 - if end != 0 { - for _, file := range files[start : end+1] { - width = max(width, ansi.StringWidth(file)) - } - } - c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth)) - c.list.SetSize(c.width, c.height) + c.updateSize() } // Close closes the completions popup. @@ -158,14 +149,17 @@ func (c *Completions) Filter(query string) { c.query = query c.list.SetFilter(query) - // recalculate width by using just the visible items + c.updateSize() +} + +func (c *Completions) updateSize() { items := c.list.FilteredItems() start, end := c.list.VisibleItemIndices() width := 0 - if end != 0 { - for _, item := range items[start : end+1] { - width = max(width, ansi.StringWidth(item.(interface{ Text() string }).Text())) - } + for i := start; i <= end; i++ { + item := c.list.ItemAt(i) + s := item.(interface{ Text() string }).Text() + width = max(width, ansi.StringWidth(s)) } c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth)) c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight)) From 2d0a0e2764686ccb503f91215a22c911fada30c0 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 4 Feb 2026 17:08:09 -0300 Subject: [PATCH 039/125] fix(ui): fix bug preventing pasting text on windows (#2126) Fixes #2118 --- internal/ui/model/ui.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a77f55fb919cdb7c809d86e698c96265797da5b3..d84d95c516892e8fa9538664dc0a2549dfa09fe1 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2921,6 +2921,9 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { // Otherwise, paste as text. paths := fsext.ParsePastedFiles(msg.Content) allExistsAndValid := func() bool { + if len(paths) == 0 { + return false + } for _, path := range paths { if _, err := os.Stat(path); os.IsNotExist(err) { return false From afed74cbee977bc05541bc9106dbe2f698dcd987 Mon Sep 17 00:00:00 2001 From: Nick Grimshaw Date: Thu, 5 Feb 2026 10:17:14 +0000 Subject: [PATCH 040/125] fix(ui): api key dialog typo (#2131) --- internal/ui/dialog/api_key_input.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index 0ca50b8fe7f8899f16aac8428caa796c5da89610..cb00477d3d5fb0f4aecb9167aa300980862e9132 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -76,7 +76,7 @@ func NewAPIKeyInput( m.input = textinput.New() m.input.SetVirtualCursor(false) - m.input.Placeholder = "Enter you API key..." + m.input.Placeholder = "Enter your API key..." m.input.SetStyles(com.Styles.TextInput) m.input.Focus() m.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding From dd78d5d9382c250745f7249f1fd693a558c34ce5 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 5 Feb 2026 07:17:56 -0300 Subject: [PATCH 041/125] chore(legal): @nickgrim 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 57bdae4b7b2676479f37efad425ae2234aad3d34..ba3015dbbac51fb88f9b207b57708280885733de 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1191,6 +1191,14 @@ "created_at": "2026-02-04T19:22:33Z", "repoId": 987670088, "pullRequestNo": 2124 + }, + { + "name": "nickgrim", + "id": 8376, + "comment_id": 3852565144, + "created_at": "2026-02-05T10:17:46Z", + "repoId": 987670088, + "pullRequestNo": 2131 } ] } \ No newline at end of file From fd437468b74e250f4d197b29c7857ce1ebbb406e Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 5 Feb 2026 07:44:24 -0300 Subject: [PATCH 042/125] fix(ui): consistent box sizing (#2127) * fix(ui): width Signed-off-by: Carlos Alexandro Becker * fix: simplify Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- internal/ui/chat/agent.go | 14 ++++++------- internal/ui/chat/assistant.go | 10 ++++----- internal/ui/chat/bash.go | 19 ++++++++--------- internal/ui/chat/diagnostics.go | 7 +++---- internal/ui/chat/fetch.go | 27 +++++++++++-------------- internal/ui/chat/file.go | 27 +++++++++++-------------- internal/ui/chat/generic.go | 9 ++++----- internal/ui/chat/lsp_restart.go | 7 +++---- internal/ui/chat/mcp.go | 11 +++++----- internal/ui/chat/messages.go | 5 ----- internal/ui/chat/references.go | 7 +++---- internal/ui/chat/search.go | 36 +++++++++++++++------------------ internal/ui/chat/todos.go | 9 ++++----- internal/ui/chat/tools.go | 8 -------- internal/ui/chat/user.go | 14 ++++++------- 15 files changed, 86 insertions(+), 124 deletions(-) diff --git a/internal/ui/chat/agent.go b/internal/ui/chat/agent.go index c2a439ff23d0bd046b75076ea30de68b60cdcc54..4784b314169f92efe4e80bf875eea5fd3780fe86 100644 --- a/internal/ui/chat/agent.go +++ b/internal/ui/chat/agent.go @@ -99,7 +99,6 @@ type AgentToolRenderContext struct { // RenderTool implements the [ToolRenderer] interface. func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 { return pendingTool(sty, "Agent", opts.Anim) } @@ -110,7 +109,7 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts prompt := params.Prompt prompt = strings.ReplaceAll(prompt, "\n", " ") - header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts.Compact) + header := toolHeader(sty, opts.Status, "Agent", width, opts.Compact) if opts.Compact { return header } @@ -120,7 +119,7 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts taskTagWidth := lipgloss.Width(taskTag) // Calculate remaining width for prompt. - remainingWidth := min(cappedWidth-taskTagWidth-3, maxTextWidth-taskTagWidth-3) // -3 for spacing + remainingWidth := width - taskTagWidth - 3 // -3 for spacing promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) @@ -157,7 +156,7 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts // Add body content when completed. if opts.HasResult() && opts.Result.Content != "" { - body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent) + body := toolOutputMarkdownContent(sty, opts.Result.Content, width-toolBodyLeftPaddingTotal, opts.ExpandedContent) return joinToolParts(result, body) } @@ -230,7 +229,6 @@ type agenticFetchParams struct { // RenderTool implements the [ToolRenderer] interface. func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 { return pendingTool(sty, "Agentic Fetch", opts.Anim) } @@ -247,7 +245,7 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int toolParams = append(toolParams, params.URL) } - header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Agentic Fetch", width, opts.Compact, toolParams...) if opts.Compact { return header } @@ -257,7 +255,7 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int promptTagWidth := lipgloss.Width(promptTag) // Calculate remaining width for prompt text. - remainingWidth := min(cappedWidth-promptTagWidth-3, maxTextWidth-promptTagWidth-3) // -3 for spacing + remainingWidth := width - promptTagWidth - 3 // -3 for spacing promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) @@ -294,7 +292,7 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int // Add body content when completed. if opts.HasResult() && opts.Result.Content != "" { - body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent) + body := toolOutputMarkdownContent(sty, opts.Result.Content, width-toolBodyLeftPaddingTotal, opts.ExpandedContent) return joinToolParts(result, body) } diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 4ce71dda2515e5489900c33eb716e1d6d884409a..b9aa19456eb05739484c0b4d1a28813a7b46bb11 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -79,22 +79,20 @@ func (a *AssistantMessageItem) ID() string { // RawRender implements [MessageItem]. func (a *AssistantMessageItem) RawRender(width int) string { - cappedWidth := cappedMessageWidth(width) - var spinner string if a.isSpinning() { spinner = a.renderSpinning() } - content, height, ok := a.getCachedRender(cappedWidth) + content, height, ok := a.getCachedRender(width) if !ok { - content = a.renderMessageContent(cappedWidth) + content = a.renderMessageContent(width) height = lipgloss.Height(content) // cache the rendered content - a.setCachedRender(content, cappedWidth, height) + a.setCachedRender(content, width, height) } - highlightedContent := a.renderHighlighted(content, cappedWidth, height) + highlightedContent := a.renderHighlighted(content, width, height) if spinner != "" { if highlightedContent != "" { highlightedContent += "\n\n" diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index 18be27ee01b4fcc21749789fc65ec0b71c2b0d4b..445043aef9809b69126d0c409596a299f6a3aa58 100644 --- a/internal/ui/chat/bash.go +++ b/internal/ui/chat/bash.go @@ -39,7 +39,6 @@ type BashToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Bash", opts.Anim) } @@ -58,7 +57,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * if meta.Background { description := cmp.Or(meta.Description, params.Command) content := "Command: " + params.Command + "\n" + opts.Result.Content - return renderJobTool(sty, opts, cappedWidth, "Start", meta.ShellID, description, content) + return renderJobTool(sty, opts, width, "Start", meta.ShellID, description, content) } // Regular bash command. @@ -69,12 +68,12 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "background", "true") } - header := toolHeader(sty, opts.Status, "Bash", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Bash", width, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -90,7 +89,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return header } - bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + bodyWidth := width - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } @@ -121,14 +120,13 @@ type JobOutputToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Job", opts.Anim) } var params tools.JobOutputParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) } var description string @@ -143,7 +141,7 @@ func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, o if opts.HasResult() { content = opts.Result.Content } - return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content) + return renderJobTool(sty, opts, width, "Output", params.ShellID, description, content) } // ----------------------------------------------------------------------------- @@ -172,14 +170,13 @@ type JobKillToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Job", opts.Anim) } var params tools.JobKillParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) } var description string @@ -194,7 +191,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt if opts.HasResult() { content = opts.Result.Content } - return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content) + return renderJobTool(sty, opts, width, "Kill", params.ShellID, description, content) } // renderJobTool renders a job-related tool with the common pattern: diff --git a/internal/ui/chat/diagnostics.go b/internal/ui/chat/diagnostics.go index 68d2ac4a00dc880c27904468008fb8f6b2fcf9c5..16dbda3563b55d881944eea4328d1f2ff99d2d87 100644 --- a/internal/ui/chat/diagnostics.go +++ b/internal/ui/chat/diagnostics.go @@ -35,7 +35,6 @@ type DiagnosticsToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Diagnostics", opts.Anim) } @@ -49,12 +48,12 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, mainParam = fsext.PrettyPath(params.FilePath) } - header := toolHeader(sty, opts.Status, "Diagnostics", cappedWidth, opts.Compact, mainParam) + header := toolHeader(sty, opts.Status, "Diagnostics", width, opts.Compact, mainParam) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -62,7 +61,7 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, return header } - bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + bodyWidth := width - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } diff --git a/internal/ui/chat/fetch.go b/internal/ui/chat/fetch.go index e3f3a809550385dfd0ec557e98151ffc731acc93..588b2926258b01b8579330211de83eb266a5adcd 100644 --- a/internal/ui/chat/fetch.go +++ b/internal/ui/chat/fetch.go @@ -34,14 +34,13 @@ type FetchToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Fetch", opts.Anim) } var params tools.FetchParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) } toolParams := []string{params.URL} @@ -52,12 +51,12 @@ func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout)) } - header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Fetch", width, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -67,7 +66,7 @@ func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts // Determine file extension for syntax highlighting based on format. file := getFileExtensionForFormat(params.Format) - body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.ExpandedContent) + body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, width, opts.ExpandedContent) return joinToolParts(header, body) } @@ -109,23 +108,22 @@ type WebFetchToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Fetch", opts.Anim) } var params tools.WebFetchParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) } toolParams := []string{params.URL} - header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Fetch", width, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -133,7 +131,7 @@ func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, op return header } - body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent) + body := toolOutputMarkdownContent(sty, opts.Result.Content, width, opts.ExpandedContent) return joinToolParts(header, body) } @@ -163,23 +161,22 @@ type WebSearchToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Search", opts.Anim) } var params tools.WebSearchParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) } toolParams := []string{params.Query} - header := toolHeader(sty, opts.Status, "Search", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Search", width, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -187,6 +184,6 @@ func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, o return header } - body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent) + body := toolOutputMarkdownContent(sty, opts.Result.Content, width, opts.ExpandedContent) return joinToolParts(header, body) } diff --git a/internal/ui/chat/file.go b/internal/ui/chat/file.go index d558f79d597871bf6074d33c76b44549ee6725d5..13cb5104233af51756806cebb9b545b3bb5076f0 100644 --- a/internal/ui/chat/file.go +++ b/internal/ui/chat/file.go @@ -37,14 +37,13 @@ type ViewToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "View", opts.Anim) } var params tools.ViewParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) } file := fsext.PrettyPath(params.FilePath) @@ -56,12 +55,12 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset)) } - header := toolHeader(sty, opts.Status, "View", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "View", width, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -87,7 +86,7 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * } // Render code content with syntax highlighting. - body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, cappedWidth, opts.ExpandedContent) + body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, width, opts.ExpandedContent) return joinToolParts(header, body) } @@ -117,23 +116,22 @@ type WriteToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Write", opts.Anim) } var params tools.WriteParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) } file := fsext.PrettyPath(params.FilePath) - header := toolHeader(sty, opts.Status, "Write", cappedWidth, opts.Compact, file) + header := toolHeader(sty, opts.Status, "Write", width, opts.Compact, file) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -142,7 +140,7 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts } // Render code content with syntax highlighting. - body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent) + body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, width, opts.ExpandedContent) return joinToolParts(header, body) } @@ -303,14 +301,13 @@ type DownloadToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Download", opts.Anim) } var params tools.DownloadParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) } toolParams := []string{params.URL} @@ -321,12 +318,12 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout)) } - header := toolHeader(sty, opts.Status, "Download", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Download", width, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -334,7 +331,7 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op return header } - bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + bodyWidth := width - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } diff --git a/internal/ui/chat/generic.go b/internal/ui/chat/generic.go index 6b0ac433028daf7a06c57f85c7799250e9652f6f..269bf651f7ec402d5e41aecabfb7aee0d9272cb5 100644 --- a/internal/ui/chat/generic.go +++ b/internal/ui/chat/generic.go @@ -31,7 +31,6 @@ 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() { @@ -40,7 +39,7 @@ func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opt 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) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) } var toolParams []string @@ -49,12 +48,12 @@ func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opt toolParams = append(toolParams, string(parsed)) } - header := toolHeader(sty, opts.Status, name, cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, name, width, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -62,7 +61,7 @@ func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opt return header } - bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + bodyWidth := width - toolBodyLeftPaddingTotal // Handle image data. if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") { diff --git a/internal/ui/chat/lsp_restart.go b/internal/ui/chat/lsp_restart.go index 66c316fcaf7c949711babeb9ebe864e558ae5bc0..4ee188a42428167314cd34aa60828cb87d121b79 100644 --- a/internal/ui/chat/lsp_restart.go +++ b/internal/ui/chat/lsp_restart.go @@ -30,7 +30,6 @@ type LSPRestartToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (r *LSPRestartToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Restart LSP", opts.Anim) } @@ -43,12 +42,12 @@ func (r *LSPRestartToolRenderContext) RenderTool(sty *styles.Styles, width int, toolParams = append(toolParams, params.Name) } - header := toolHeader(sty, opts.Status, "Restart LSP", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Restart LSP", width, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -56,7 +55,7 @@ func (r *LSPRestartToolRenderContext) RenderTool(sty *styles.Styles, width int, return header } - bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + bodyWidth := width - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } diff --git a/internal/ui/chat/mcp.go b/internal/ui/chat/mcp.go index c4d124e7381a9ddaa39f56750367d3f2cf4d207f..5cf750bacf7227744f06cc2d5253d98ad1713cbd 100644 --- a/internal/ui/chat/mcp.go +++ b/internal/ui/chat/mcp.go @@ -32,10 +32,9 @@ type MCPToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) toolNameParts := strings.SplitN(opts.ToolCall.Name, "_", 3) if len(toolNameParts) != 3 { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid tool name"}, cappedWidth) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid tool name"}, width) } mcpName := prettyName(toolNameParts[1]) toolName := prettyName(toolNameParts[2]) @@ -51,7 +50,7 @@ func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *T 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) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) } var toolParams []string @@ -60,12 +59,12 @@ func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *T toolParams = append(toolParams, string(parsed)) } - header := toolHeader(sty, opts.Status, name, cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, name, width, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -73,7 +72,7 @@ func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *T return header } - bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + bodyWidth := width - toolBodyLeftPaddingTotal // see if the result is json var result json.RawMessage var body string diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 0c5668a20d52c5975dc63cb37da8090e9aa0ca7f..0c5a3ed6a8a9d65d26cf67adff5f308e3d82a929 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -245,11 +245,6 @@ func (a *AssistantInfoItem) renderContent(width int) string { return common.Section(a.sty, assistant, width) } -// cappedMessageWidth returns the maximum width for message content for readability. -func cappedMessageWidth(availableWidth int) int { - return min(availableWidth-MessageLeftPaddingTotal, maxTextWidth) -} - // ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It // returns all parts of the message as [MessageItem]s. // diff --git a/internal/ui/chat/references.go b/internal/ui/chat/references.go index 2d7efe8df3ed38bf3768d7ae13c433fc05c17418..25fee7a15710c5ce1f470e470ff3b491da5000c3 100644 --- a/internal/ui/chat/references.go +++ b/internal/ui/chat/references.go @@ -31,7 +31,6 @@ type ReferencesToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (r *ReferencesToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Find References", opts.Anim) } @@ -44,12 +43,12 @@ func (r *ReferencesToolRenderContext) RenderTool(sty *styles.Styles, width int, toolParams = append(toolParams, "path", fsext.PrettyPath(params.Path)) } - header := toolHeader(sty, opts.Status, "Find References", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Find References", width, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -57,7 +56,7 @@ func (r *ReferencesToolRenderContext) RenderTool(sty *styles.Styles, width int, return header } - bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + bodyWidth := width - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } diff --git a/internal/ui/chat/search.go b/internal/ui/chat/search.go index 2342f671fdaed3bfdcf56619864bd3b60987d8a6..2a252936f63c41dd18afde4ef725ed43a3c23a95 100644 --- a/internal/ui/chat/search.go +++ b/internal/ui/chat/search.go @@ -35,14 +35,13 @@ type GlobToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Glob", opts.Anim) } var params tools.GlobParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) } toolParams := []string{params.Pattern} @@ -50,12 +49,12 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "path", params.Path) } - header := toolHeader(sty, opts.Status, "Glob", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Glob", width, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -63,7 +62,7 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return header } - bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + bodyWidth := width - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } @@ -94,14 +93,13 @@ type GrepToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Grep", opts.Anim) } var params tools.GrepParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) } toolParams := []string{params.Pattern} @@ -115,12 +113,12 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "literal", "true") } - header := toolHeader(sty, opts.Status, "Grep", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Grep", width, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -128,7 +126,7 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return header } - bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + bodyWidth := width - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } @@ -159,14 +157,13 @@ type LSToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "List", opts.Anim) } var params tools.LSParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) } path := params.Path @@ -175,12 +172,12 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To } path = fsext.PrettyPath(path) - header := toolHeader(sty, opts.Status, "List", cappedWidth, opts.Compact, path) + header := toolHeader(sty, opts.Status, "List", width, opts.Compact, path) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -188,7 +185,7 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To return header } - bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + bodyWidth := width - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } @@ -219,14 +216,13 @@ type SourcegraphToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Sourcegraph", opts.Anim) } var params tools.SourcegraphParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) } toolParams := []string{params.Query} @@ -237,12 +233,12 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow)) } - header := toolHeader(sty, opts.Status, "Sourcegraph", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Sourcegraph", width, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } @@ -250,7 +246,7 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, return header } - bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + bodyWidth := width - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } diff --git a/internal/ui/chat/todos.go b/internal/ui/chat/todos.go index 5678d0e47f4c3a808c13c1dc6209f9194e9f9482..42e9762b8bf1685495b65626bc36b1b3f45031a8 100644 --- a/internal/ui/chat/todos.go +++ b/internal/ui/chat/todos.go @@ -39,7 +39,6 @@ type TodosToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "To-Do", opts.Anim) } @@ -82,7 +81,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts } else { headerText = fmt.Sprintf("created %d todos", meta.Total) } - body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth) + body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, width) } else { // Build header based on what changed. hasCompleted := len(meta.JustCompleted) > 0 @@ -108,7 +107,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts // Build body with details. if allCompleted { // Show all todos when all are completed, like when created. - body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth) + body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, width) } else if meta.JustStarted != "" { body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") + sty.Base.Render(meta.JustStarted) @@ -119,12 +118,12 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts } toolParams := []string{headerText} - header := toolHeader(sty, opts.Status, "To-Do", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "To-Do", width, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { return joinToolParts(header, earlyState) } diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index f7702cc1fe516bb3dee7d57ce15fed050299019f..07bf40f96b08c24907f0bd65d80cebfb74eae58b 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -295,9 +295,6 @@ func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { // RawRender implements [MessageItem]. func (t *baseToolMessageItem) RawRender(width int) string { toolItemWidth := width - MessageLeftPaddingTotal - if t.hasCappedWidth { - toolItemWidth = cappedMessageWidth(width) - } content, height, ok := t.getCachedRender(toolItemWidth) // if we are spinning or there is no cache rerender @@ -773,11 +770,6 @@ func roundedEnumerator(lPadding, width int) tree.Enumerator { func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string { content = stringext.NormalizeSpace(content) - // Cap width for readability. - if width > maxTextWidth { - width = maxTextWidth - } - renderer := common.PlainMarkdownRenderer(sty, width) rendered, err := renderer.Render(content) if err != nil { diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go index 91211590ce66dd0dd7edbde03becdf469e26b521..814a0270aad00bbf85c78629ffdfaf01a17c2e7f 100644 --- a/internal/ui/chat/user.go +++ b/internal/ui/chat/user.go @@ -36,15 +36,13 @@ func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachment // RawRender implements [MessageItem]. func (m *UserMessageItem) RawRender(width int) string { - cappedWidth := cappedMessageWidth(width) - - content, height, ok := m.getCachedRender(cappedWidth) + content, height, ok := m.getCachedRender(width) // cache hit if ok { - return m.renderHighlighted(content, cappedWidth, height) + return m.renderHighlighted(content, width, height) } - renderer := common.MarkdownRenderer(m.sty, cappedWidth) + renderer := common.MarkdownRenderer(m.sty, width) msgContent := strings.TrimSpace(m.message.Content().Text) result, err := renderer.Render(msgContent) @@ -55,7 +53,7 @@ func (m *UserMessageItem) RawRender(width int) string { } if len(m.message.BinaryContent()) > 0 { - attachmentsStr := m.renderAttachments(cappedWidth) + attachmentsStr := m.renderAttachments(width) if content == "" { content = attachmentsStr } else { @@ -64,8 +62,8 @@ func (m *UserMessageItem) RawRender(width int) string { } height = lipgloss.Height(content) - m.setCachedRender(content, cappedWidth, height) - return m.renderHighlighted(content, cappedWidth, height) + m.setCachedRender(content, width, height) + return m.renderHighlighted(content, width, height) } // Render implements MessageItem. From 9013bb012202eae93a936273782014f5c6ae35d2 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 5 Feb 2026 12:02:16 +0100 Subject: [PATCH 043/125] refactor: remove old tui (#2008) --- go.mod | 11 +- go.sum | 18 - internal/app/app.go | 6 +- internal/cmd/root.go | 40 +- internal/cmd/root_test.go | 160 -- internal/format/spinner.go | 13 +- internal/tui/components/anim/anim.go | 447 ----- internal/tui/components/chat/chat.go | 782 -------- .../tui/components/chat/editor/clipboard.go | 8 - .../chat/editor/clipboard_not_supported.go | 7 - .../chat/editor/clipboard_supported.go | 15 - internal/tui/components/chat/editor/editor.go | 780 -------- internal/tui/components/chat/editor/keys.go | 77 - internal/tui/components/chat/header/header.go | 160 -- .../tui/components/chat/messages/messages.go | 461 ----- .../tui/components/chat/messages/renderer.go | 1403 ------------- internal/tui/components/chat/messages/tool.go | 877 -------- .../tui/components/chat/sidebar/sidebar.go | 608 ------ internal/tui/components/chat/splash/keys.go | 58 - internal/tui/components/chat/splash/splash.go | 874 -------- internal/tui/components/chat/todos/todos.go | 67 - .../tui/components/completions/completions.go | 308 --- internal/tui/components/completions/keys.go | 72 - internal/tui/components/core/core.go | 207 -- internal/tui/components/core/layout/layout.go | 27 - internal/tui/components/core/status/status.go | 113 -- internal/tui/components/core/status_test.go | 144 -- .../AllFieldsWithExtraContent.golden | 1 - .../core/testdata/TestStatus/Default.golden | 1 - .../TestStatus/EmptyDescription.golden | 1 - .../TestStatus/LongDescription.golden | 1 - .../testdata/TestStatus/NarrowWidth.golden | 1 - .../core/testdata/TestStatus/NoIcon.golden | 1 - .../TestStatus/VeryNarrowWidth.golden | 1 - .../testdata/TestStatus/WithColors.golden | 1 - .../testdata/TestStatus/WithCustomIcon.golden | 1 - .../TestStatus/WithExtraContent.golden | 1 - .../TestStatusTruncation/Width20.golden | 1 - .../TestStatusTruncation/Width30.golden | 1 - .../TestStatusTruncation/Width40.golden | 1 - .../TestStatusTruncation/Width50.golden | 1 - .../TestStatusTruncation/Width60.golden | 1 - .../components/dialogs/commands/arguments.go | 245 --- .../components/dialogs/commands/commands.go | 479 ----- .../tui/components/dialogs/commands/keys.go | 133 -- .../components/dialogs/copilot/device_flow.go | 281 --- internal/tui/components/dialogs/dialogs.go | 165 -- .../dialogs/filepicker/filepicker.go | 260 --- .../tui/components/dialogs/filepicker/keys.go | 80 - .../components/dialogs/hyper/device_flow.go | 267 --- internal/tui/components/dialogs/keys.go | 43 - .../tui/components/dialogs/models/apikey.go | 203 -- .../tui/components/dialogs/models/keys.go | 120 -- .../tui/components/dialogs/models/list.go | 333 ---- .../dialogs/models/list_recent_test.go | 369 ---- .../tui/components/dialogs/models/models.go | 549 ----- .../components/dialogs/permissions/keys.go | 113 -- .../dialogs/permissions/permissions.go | 899 --------- internal/tui/components/dialogs/quit/keys.go | 75 - internal/tui/components/dialogs/quit/quit.go | 120 -- .../components/dialogs/reasoning/reasoning.go | 264 --- .../tui/components/dialogs/sessions/keys.go | 67 - .../components/dialogs/sessions/sessions.go | 181 -- internal/tui/components/files/files.go | 146 -- internal/tui/components/image/image.go | 86 - internal/tui/components/image/load.go | 169 -- internal/tui/components/logo/logo.go | 346 ---- internal/tui/components/logo/rand.go | 24 - internal/tui/components/lsp/lsp.go | 144 -- internal/tui/components/mcp/mcp.go | 138 -- internal/tui/exp/list/filterable.go | 329 --- internal/tui/exp/list/filterable_group.go | 315 --- internal/tui/exp/list/filterable_test.go | 68 - internal/tui/exp/list/grouped.go | 100 - internal/tui/exp/list/items.go | 399 ---- internal/tui/exp/list/keys.go | 76 - internal/tui/exp/list/list.go | 1775 ----------------- internal/tui/exp/list/list_test.go | 653 ------ ...hould_create_simple_filterable_list.golden | 10 - ...o_to_selected_item_at_the_beginning.golden | 10 - ...ted_item_at_the_beginning_backwards.golden | 10 - ...n_list_that_does_not_fits_the_items.golden | 10 - ..._the_items_and_has_multi_line_items.golden | 10 - ..._and_has_multi_line_items_backwards.golden | 10 - ...t_does_not_fits_the_items_backwards.golden | 10 - ...sitions_in_list_that_fits_the_items.golden | 20 - ..._list_that_fits_the_items_backwards.golden | 20 - .../should_move_viewport_down.golden | 10 - .../should_move_viewport_down_and_up.golden | 10 - .../should_move_viewport_up.golden | 10 - .../should_move_viewport_up_and_down.golden | 10 - ...are_at_the_bottom_in_backwards_list.golden | 10 - ...d_we_are_at_the_top_in_forward_list.golden | 10 - ...appended_and_we_are_in_forward_list.golden | 10 - ...pended_and_we_are_in_backwards_list.golden | 10 - ...d_but_we_moved_down_in_forward_list.golden | 10 - ...d_but_we_moved_up_in_backwards_list.golden | 10 - ..._above_is_decreases_in_forward_list.golden | 10 - ...bove_is_increased_in_backwards_list.golden | 10 - ..._above_is_increased_in_forward_list.golden | 10 - ...elow_is_decreases_in_backwards_list.golden | 10 - ...elow_is_increased_in_backwards_list.golden | 10 - ..._below_is_increased_in_forward_list.golden | 10 - internal/tui/highlight/highlight.go | 54 - internal/tui/keys.go | 45 - internal/tui/page/chat/chat.go | 1407 ------------- internal/tui/page/chat/keys.go | 53 - internal/tui/page/chat/pills.go | 125 -- internal/tui/page/page.go | 8 - internal/tui/styles/charmtone.go | 83 - internal/tui/styles/chroma.go | 79 - internal/tui/styles/icons.go | 48 - internal/tui/styles/markdown.go | 205 -- internal/tui/styles/theme.go | 709 ------- internal/tui/tui.go | 712 ------- internal/tui/util/shell.go | 15 - internal/tui/util/util.go | 45 - internal/ui/common/common.go | 4 +- internal/ui/common/diff.go | 2 +- internal/ui/dialog/actions.go | 14 +- internal/ui/dialog/api_key_input.go | 4 +- internal/ui/dialog/arguments.go | 4 +- internal/ui/dialog/models.go | 4 +- internal/ui/dialog/oauth.go | 10 +- internal/ui/dialog/sessions.go | 8 +- .../{tui/exp => ui}/diffview/Taskfile.yaml | 0 internal/{tui/exp => ui}/diffview/chroma.go | 0 internal/{tui/exp => ui}/diffview/diffview.go | 0 .../{tui/exp => ui}/diffview/diffview_test.go | 2 +- internal/{tui/exp => ui}/diffview/split.go | 0 internal/{tui/exp => ui}/diffview/style.go | 0 .../diffview/testdata/TestDefault.after | 0 .../diffview/testdata/TestDefault.before | 0 .../Split/CustomContextLines/DarkMode.golden | 0 .../Split/CustomContextLines/LightMode.golden | 0 .../Split/Default/DarkMode.golden | 0 .../Split/Default/LightMode.golden | 0 .../Split/LargeWidth/DarkMode.golden | 0 .../Split/LargeWidth/LightMode.golden | 0 .../Split/MultipleHunks/DarkMode.golden | 0 .../Split/MultipleHunks/LightMode.golden | 0 .../TestDiffView/Split/Narrow/DarkMode.golden | 0 .../Split/Narrow/LightMode.golden | 0 .../Split/NoLineNumbers/DarkMode.golden | 0 .../Split/NoLineNumbers/LightMode.golden | 0 .../Split/NoSyntaxHighlight/DarkMode.golden | 0 .../Split/NoSyntaxHighlight/LightMode.golden | 0 .../Split/SmallWidth/DarkMode.golden | 0 .../Split/SmallWidth/LightMode.golden | 0 .../CustomContextLines/DarkMode.golden | 0 .../CustomContextLines/LightMode.golden | 0 .../Unified/Default/DarkMode.golden | 0 .../Unified/Default/LightMode.golden | 0 .../Unified/LargeWidth/DarkMode.golden | 0 .../Unified/LargeWidth/LightMode.golden | 0 .../Unified/MultipleHunks/DarkMode.golden | 0 .../Unified/MultipleHunks/LightMode.golden | 0 .../Unified/Narrow/DarkMode.golden | 0 .../Unified/Narrow/LightMode.golden | 0 .../Unified/NoLineNumbers/DarkMode.golden | 0 .../Unified/NoLineNumbers/LightMode.golden | 0 .../Unified/NoSyntaxHighlight/DarkMode.golden | 0 .../NoSyntaxHighlight/LightMode.golden | 0 .../Unified/SmallWidth/DarkMode.golden | 0 .../Unified/SmallWidth/LightMode.golden | 0 .../Split/HeightOf001.golden | 0 .../Split/HeightOf002.golden | 0 .../Split/HeightOf003.golden | 0 .../Split/HeightOf004.golden | 0 .../Split/HeightOf005.golden | 0 .../Split/HeightOf006.golden | 0 .../Split/HeightOf007.golden | 0 .../Split/HeightOf008.golden | 0 .../Split/HeightOf009.golden | 0 .../Split/HeightOf010.golden | 0 .../Split/HeightOf011.golden | 0 .../Split/HeightOf012.golden | 0 .../Split/HeightOf013.golden | 0 .../Split/HeightOf014.golden | 0 .../Split/HeightOf015.golden | 0 .../Split/HeightOf016.golden | 0 .../Split/HeightOf017.golden | 0 .../Split/HeightOf018.golden | 0 .../Split/HeightOf019.golden | 0 .../Split/HeightOf020.golden | 0 .../Unified/HeightOf001.golden | 0 .../Unified/HeightOf002.golden | 0 .../Unified/HeightOf003.golden | 0 .../Unified/HeightOf004.golden | 0 .../Unified/HeightOf005.golden | 0 .../Unified/HeightOf006.golden | 0 .../Unified/HeightOf007.golden | 0 .../Unified/HeightOf008.golden | 0 .../Unified/HeightOf009.golden | 0 .../Unified/HeightOf010.golden | 0 .../Unified/HeightOf011.golden | 0 .../Unified/HeightOf012.golden | 0 .../Unified/HeightOf013.golden | 0 .../Unified/HeightOf014.golden | 0 .../Unified/HeightOf015.golden | 0 .../Unified/HeightOf016.golden | 0 .../Unified/HeightOf017.golden | 0 .../Unified/HeightOf018.golden | 0 .../Unified/HeightOf019.golden | 0 .../Unified/HeightOf020.golden | 0 .../TestDiffViewLineBreakIssue/Split.golden | 0 .../TestDiffViewLineBreakIssue/Unified.golden | 0 .../testdata/TestDiffViewTabs/Split.golden | 0 .../testdata/TestDiffViewTabs/Unified.golden | 0 .../TestDiffViewWidth/Split/WidthOf001.golden | 0 .../TestDiffViewWidth/Split/WidthOf002.golden | 0 .../TestDiffViewWidth/Split/WidthOf003.golden | 0 .../TestDiffViewWidth/Split/WidthOf004.golden | 0 .../TestDiffViewWidth/Split/WidthOf005.golden | 0 .../TestDiffViewWidth/Split/WidthOf006.golden | 0 .../TestDiffViewWidth/Split/WidthOf007.golden | 0 .../TestDiffViewWidth/Split/WidthOf008.golden | 0 .../TestDiffViewWidth/Split/WidthOf009.golden | 0 .../TestDiffViewWidth/Split/WidthOf010.golden | 0 .../TestDiffViewWidth/Split/WidthOf011.golden | 0 .../TestDiffViewWidth/Split/WidthOf012.golden | 0 .../TestDiffViewWidth/Split/WidthOf013.golden | 0 .../TestDiffViewWidth/Split/WidthOf014.golden | 0 .../TestDiffViewWidth/Split/WidthOf015.golden | 0 .../TestDiffViewWidth/Split/WidthOf016.golden | 0 .../TestDiffViewWidth/Split/WidthOf017.golden | 0 .../TestDiffViewWidth/Split/WidthOf018.golden | 0 .../TestDiffViewWidth/Split/WidthOf019.golden | 0 .../TestDiffViewWidth/Split/WidthOf020.golden | 0 .../TestDiffViewWidth/Split/WidthOf021.golden | 0 .../TestDiffViewWidth/Split/WidthOf022.golden | 0 .../TestDiffViewWidth/Split/WidthOf023.golden | 0 .../TestDiffViewWidth/Split/WidthOf024.golden | 0 .../TestDiffViewWidth/Split/WidthOf025.golden | 0 .../TestDiffViewWidth/Split/WidthOf026.golden | 0 .../TestDiffViewWidth/Split/WidthOf027.golden | 0 .../TestDiffViewWidth/Split/WidthOf028.golden | 0 .../TestDiffViewWidth/Split/WidthOf029.golden | 0 .../TestDiffViewWidth/Split/WidthOf030.golden | 0 .../TestDiffViewWidth/Split/WidthOf031.golden | 0 .../TestDiffViewWidth/Split/WidthOf032.golden | 0 .../TestDiffViewWidth/Split/WidthOf033.golden | 0 .../TestDiffViewWidth/Split/WidthOf034.golden | 0 .../TestDiffViewWidth/Split/WidthOf035.golden | 0 .../TestDiffViewWidth/Split/WidthOf036.golden | 0 .../TestDiffViewWidth/Split/WidthOf037.golden | 0 .../TestDiffViewWidth/Split/WidthOf038.golden | 0 .../TestDiffViewWidth/Split/WidthOf039.golden | 0 .../TestDiffViewWidth/Split/WidthOf040.golden | 0 .../TestDiffViewWidth/Split/WidthOf041.golden | 0 .../TestDiffViewWidth/Split/WidthOf042.golden | 0 .../TestDiffViewWidth/Split/WidthOf043.golden | 0 .../TestDiffViewWidth/Split/WidthOf044.golden | 0 .../TestDiffViewWidth/Split/WidthOf045.golden | 0 .../TestDiffViewWidth/Split/WidthOf046.golden | 0 .../TestDiffViewWidth/Split/WidthOf047.golden | 0 .../TestDiffViewWidth/Split/WidthOf048.golden | 0 .../TestDiffViewWidth/Split/WidthOf049.golden | 0 .../TestDiffViewWidth/Split/WidthOf050.golden | 0 .../TestDiffViewWidth/Split/WidthOf051.golden | 0 .../TestDiffViewWidth/Split/WidthOf052.golden | 0 .../TestDiffViewWidth/Split/WidthOf053.golden | 0 .../TestDiffViewWidth/Split/WidthOf054.golden | 0 .../TestDiffViewWidth/Split/WidthOf055.golden | 0 .../TestDiffViewWidth/Split/WidthOf056.golden | 0 .../TestDiffViewWidth/Split/WidthOf057.golden | 0 .../TestDiffViewWidth/Split/WidthOf058.golden | 0 .../TestDiffViewWidth/Split/WidthOf059.golden | 0 .../TestDiffViewWidth/Split/WidthOf060.golden | 0 .../TestDiffViewWidth/Split/WidthOf061.golden | 0 .../TestDiffViewWidth/Split/WidthOf062.golden | 0 .../TestDiffViewWidth/Split/WidthOf063.golden | 0 .../TestDiffViewWidth/Split/WidthOf064.golden | 0 .../TestDiffViewWidth/Split/WidthOf065.golden | 0 .../TestDiffViewWidth/Split/WidthOf066.golden | 0 .../TestDiffViewWidth/Split/WidthOf067.golden | 0 .../TestDiffViewWidth/Split/WidthOf068.golden | 0 .../TestDiffViewWidth/Split/WidthOf069.golden | 0 .../TestDiffViewWidth/Split/WidthOf070.golden | 0 .../TestDiffViewWidth/Split/WidthOf071.golden | 0 .../TestDiffViewWidth/Split/WidthOf072.golden | 0 .../TestDiffViewWidth/Split/WidthOf073.golden | 0 .../TestDiffViewWidth/Split/WidthOf074.golden | 0 .../TestDiffViewWidth/Split/WidthOf075.golden | 0 .../TestDiffViewWidth/Split/WidthOf076.golden | 0 .../TestDiffViewWidth/Split/WidthOf077.golden | 0 .../TestDiffViewWidth/Split/WidthOf078.golden | 0 .../TestDiffViewWidth/Split/WidthOf079.golden | 0 .../TestDiffViewWidth/Split/WidthOf080.golden | 0 .../TestDiffViewWidth/Split/WidthOf081.golden | 0 .../TestDiffViewWidth/Split/WidthOf082.golden | 0 .../TestDiffViewWidth/Split/WidthOf083.golden | 0 .../TestDiffViewWidth/Split/WidthOf084.golden | 0 .../TestDiffViewWidth/Split/WidthOf085.golden | 0 .../TestDiffViewWidth/Split/WidthOf086.golden | 0 .../TestDiffViewWidth/Split/WidthOf087.golden | 0 .../TestDiffViewWidth/Split/WidthOf088.golden | 0 .../TestDiffViewWidth/Split/WidthOf089.golden | 0 .../TestDiffViewWidth/Split/WidthOf090.golden | 0 .../TestDiffViewWidth/Split/WidthOf091.golden | 0 .../TestDiffViewWidth/Split/WidthOf092.golden | 0 .../TestDiffViewWidth/Split/WidthOf093.golden | 0 .../TestDiffViewWidth/Split/WidthOf094.golden | 0 .../TestDiffViewWidth/Split/WidthOf095.golden | 0 .../TestDiffViewWidth/Split/WidthOf096.golden | 0 .../TestDiffViewWidth/Split/WidthOf097.golden | 0 .../TestDiffViewWidth/Split/WidthOf098.golden | 0 .../TestDiffViewWidth/Split/WidthOf099.golden | 0 .../TestDiffViewWidth/Split/WidthOf100.golden | 0 .../TestDiffViewWidth/Split/WidthOf101.golden | 0 .../TestDiffViewWidth/Split/WidthOf102.golden | 0 .../TestDiffViewWidth/Split/WidthOf103.golden | 0 .../TestDiffViewWidth/Split/WidthOf104.golden | 0 .../TestDiffViewWidth/Split/WidthOf105.golden | 0 .../TestDiffViewWidth/Split/WidthOf106.golden | 0 .../TestDiffViewWidth/Split/WidthOf107.golden | 0 .../TestDiffViewWidth/Split/WidthOf108.golden | 0 .../TestDiffViewWidth/Split/WidthOf109.golden | 0 .../TestDiffViewWidth/Split/WidthOf110.golden | 0 .../Unified/WidthOf001.golden | 0 .../Unified/WidthOf002.golden | 0 .../Unified/WidthOf003.golden | 0 .../Unified/WidthOf004.golden | 0 .../Unified/WidthOf005.golden | 0 .../Unified/WidthOf006.golden | 0 .../Unified/WidthOf007.golden | 0 .../Unified/WidthOf008.golden | 0 .../Unified/WidthOf009.golden | 0 .../Unified/WidthOf010.golden | 0 .../Unified/WidthOf011.golden | 0 .../Unified/WidthOf012.golden | 0 .../Unified/WidthOf013.golden | 0 .../Unified/WidthOf014.golden | 0 .../Unified/WidthOf015.golden | 0 .../Unified/WidthOf016.golden | 0 .../Unified/WidthOf017.golden | 0 .../Unified/WidthOf018.golden | 0 .../Unified/WidthOf019.golden | 0 .../Unified/WidthOf020.golden | 0 .../Unified/WidthOf021.golden | 0 .../Unified/WidthOf022.golden | 0 .../Unified/WidthOf023.golden | 0 .../Unified/WidthOf024.golden | 0 .../Unified/WidthOf025.golden | 0 .../Unified/WidthOf026.golden | 0 .../Unified/WidthOf027.golden | 0 .../Unified/WidthOf028.golden | 0 .../Unified/WidthOf029.golden | 0 .../Unified/WidthOf030.golden | 0 .../Unified/WidthOf031.golden | 0 .../Unified/WidthOf032.golden | 0 .../Unified/WidthOf033.golden | 0 .../Unified/WidthOf034.golden | 0 .../Unified/WidthOf035.golden | 0 .../Unified/WidthOf036.golden | 0 .../Unified/WidthOf037.golden | 0 .../Unified/WidthOf038.golden | 0 .../Unified/WidthOf039.golden | 0 .../Unified/WidthOf040.golden | 0 .../Unified/WidthOf041.golden | 0 .../Unified/WidthOf042.golden | 0 .../Unified/WidthOf043.golden | 0 .../Unified/WidthOf044.golden | 0 .../Unified/WidthOf045.golden | 0 .../Unified/WidthOf046.golden | 0 .../Unified/WidthOf047.golden | 0 .../Unified/WidthOf048.golden | 0 .../Unified/WidthOf049.golden | 0 .../Unified/WidthOf050.golden | 0 .../Unified/WidthOf051.golden | 0 .../Unified/WidthOf052.golden | 0 .../Unified/WidthOf053.golden | 0 .../Unified/WidthOf054.golden | 0 .../Unified/WidthOf055.golden | 0 .../Unified/WidthOf056.golden | 0 .../Unified/WidthOf057.golden | 0 .../Unified/WidthOf058.golden | 0 .../Unified/WidthOf059.golden | 0 .../Unified/WidthOf060.golden | 0 .../Split/XOffsetOf00.golden | 0 .../Split/XOffsetOf01.golden | 0 .../Split/XOffsetOf02.golden | 0 .../Split/XOffsetOf03.golden | 0 .../Split/XOffsetOf04.golden | 0 .../Split/XOffsetOf05.golden | 0 .../Split/XOffsetOf06.golden | 0 .../Split/XOffsetOf07.golden | 0 .../Split/XOffsetOf08.golden | 0 .../Split/XOffsetOf09.golden | 0 .../Split/XOffsetOf10.golden | 0 .../Split/XOffsetOf11.golden | 0 .../Split/XOffsetOf12.golden | 0 .../Split/XOffsetOf13.golden | 0 .../Split/XOffsetOf14.golden | 0 .../Split/XOffsetOf15.golden | 0 .../Split/XOffsetOf16.golden | 0 .../Split/XOffsetOf17.golden | 0 .../Split/XOffsetOf18.golden | 0 .../Split/XOffsetOf19.golden | 0 .../Split/XOffsetOf20.golden | 0 .../Unified/XOffsetOf00.golden | 0 .../Unified/XOffsetOf01.golden | 0 .../Unified/XOffsetOf02.golden | 0 .../Unified/XOffsetOf03.golden | 0 .../Unified/XOffsetOf04.golden | 0 .../Unified/XOffsetOf05.golden | 0 .../Unified/XOffsetOf06.golden | 0 .../Unified/XOffsetOf07.golden | 0 .../Unified/XOffsetOf08.golden | 0 .../Unified/XOffsetOf09.golden | 0 .../Unified/XOffsetOf10.golden | 0 .../Unified/XOffsetOf11.golden | 0 .../Unified/XOffsetOf12.golden | 0 .../Unified/XOffsetOf13.golden | 0 .../Unified/XOffsetOf14.golden | 0 .../Unified/XOffsetOf15.golden | 0 .../Unified/XOffsetOf16.golden | 0 .../Unified/XOffsetOf17.golden | 0 .../Unified/XOffsetOf18.golden | 0 .../Unified/XOffsetOf19.golden | 0 .../Unified/XOffsetOf20.golden | 0 .../Split/YOffsetOf00.golden | 0 .../Split/YOffsetOf01.golden | 0 .../Split/YOffsetOf02.golden | 0 .../Split/YOffsetOf03.golden | 0 .../Split/YOffsetOf04.golden | 0 .../Split/YOffsetOf05.golden | 0 .../Split/YOffsetOf06.golden | 0 .../Split/YOffsetOf07.golden | 0 .../Split/YOffsetOf08.golden | 0 .../Split/YOffsetOf09.golden | 0 .../Split/YOffsetOf10.golden | 0 .../Split/YOffsetOf11.golden | 0 .../Split/YOffsetOf12.golden | 0 .../Split/YOffsetOf13.golden | 0 .../Split/YOffsetOf14.golden | 0 .../Split/YOffsetOf15.golden | 0 .../Split/YOffsetOf16.golden | 0 .../Unified/YOffsetOf00.golden | 0 .../Unified/YOffsetOf01.golden | 0 .../Unified/YOffsetOf02.golden | 0 .../Unified/YOffsetOf03.golden | 0 .../Unified/YOffsetOf04.golden | 0 .../Unified/YOffsetOf05.golden | 0 .../Unified/YOffsetOf06.golden | 0 .../Unified/YOffsetOf07.golden | 0 .../Unified/YOffsetOf08.golden | 0 .../Unified/YOffsetOf09.golden | 0 .../Unified/YOffsetOf10.golden | 0 .../Unified/YOffsetOf11.golden | 0 .../Unified/YOffsetOf12.golden | 0 .../Unified/YOffsetOf13.golden | 0 .../Unified/YOffsetOf14.golden | 0 .../Unified/YOffsetOf15.golden | 0 .../Unified/YOffsetOf16.golden | 0 .../Split/YOffsetOf00.golden | 0 .../Split/YOffsetOf01.golden | 0 .../Split/YOffsetOf02.golden | 0 .../Split/YOffsetOf03.golden | 0 .../Split/YOffsetOf04.golden | 0 .../Split/YOffsetOf05.golden | 0 .../Split/YOffsetOf06.golden | 0 .../Split/YOffsetOf07.golden | 0 .../Split/YOffsetOf08.golden | 0 .../Split/YOffsetOf09.golden | 0 .../Split/YOffsetOf10.golden | 0 .../Split/YOffsetOf11.golden | 0 .../Split/YOffsetOf12.golden | 0 .../Split/YOffsetOf13.golden | 0 .../Split/YOffsetOf14.golden | 0 .../Split/YOffsetOf15.golden | 0 .../Split/YOffsetOf16.golden | 0 .../Unified/YOffsetOf00.golden | 0 .../Unified/YOffsetOf01.golden | 0 .../Unified/YOffsetOf02.golden | 0 .../Unified/YOffsetOf03.golden | 0 .../Unified/YOffsetOf04.golden | 0 .../Unified/YOffsetOf05.golden | 0 .../Unified/YOffsetOf06.golden | 0 .../Unified/YOffsetOf07.golden | 0 .../Unified/YOffsetOf08.golden | 0 .../Unified/YOffsetOf09.golden | 0 .../Unified/YOffsetOf10.golden | 0 .../Unified/YOffsetOf11.golden | 0 .../Unified/YOffsetOf12.golden | 0 .../Unified/YOffsetOf13.golden | 0 .../Unified/YOffsetOf14.golden | 0 .../Unified/YOffsetOf15.golden | 0 .../Unified/YOffsetOf16.golden | 0 .../testdata/TestLineBreakIssue.after | 0 .../testdata/TestLineBreakIssue.before | 0 .../diffview/testdata/TestMultipleHunks.after | 0 .../testdata/TestMultipleHunks.before | 0 .../diffview/testdata/TestNarrow.after | 0 .../diffview/testdata/TestNarrow.before | 0 .../diffview/testdata/TestTabs.after | 0 .../diffview/testdata/TestTabs.before | 0 .../DefaultContextLines/Content.golden | 0 .../DefaultContextLines/JSON.golden | 0 .../DefaultContextLinesPlusOne/Content.golden | 0 .../DefaultContextLinesPlusOne/JSON.golden | 0 .../DefaultContextLinesPlusTwo/Content.golden | 0 .../DefaultContextLinesPlusTwo/JSON.golden | 0 .../testdata/TestUdiff/Unified.golden | 0 .../{tui/exp => ui}/diffview/udiff_test.go | 0 internal/{tui/exp => ui}/diffview/util.go | 0 .../{tui/exp => ui}/diffview/util_test.go | 0 internal/ui/image/image.go | 6 +- internal/ui/logo/logo.go | 15 +- internal/ui/model/filter.go | 22 + internal/ui/model/onboarding.go | 4 +- internal/ui/model/session.go | 6 +- internal/ui/model/sidebar.go | 2 +- internal/ui/model/status.go | 20 +- internal/ui/model/ui.go | 104 +- internal/ui/styles/styles.go | 2 +- .../{uiutil/uiutil.go => ui/util/util.go} | 6 +- internal/uicmd/uicmd.go | 314 --- 518 files changed, 144 insertions(+), 22039 deletions(-) delete mode 100644 internal/cmd/root_test.go delete mode 100644 internal/tui/components/anim/anim.go delete mode 100644 internal/tui/components/chat/chat.go delete mode 100644 internal/tui/components/chat/editor/clipboard.go delete mode 100644 internal/tui/components/chat/editor/clipboard_not_supported.go delete mode 100644 internal/tui/components/chat/editor/clipboard_supported.go delete mode 100644 internal/tui/components/chat/editor/editor.go delete mode 100644 internal/tui/components/chat/editor/keys.go delete mode 100644 internal/tui/components/chat/header/header.go delete mode 100644 internal/tui/components/chat/messages/messages.go delete mode 100644 internal/tui/components/chat/messages/renderer.go delete mode 100644 internal/tui/components/chat/messages/tool.go delete mode 100644 internal/tui/components/chat/sidebar/sidebar.go delete mode 100644 internal/tui/components/chat/splash/keys.go delete mode 100644 internal/tui/components/chat/splash/splash.go delete mode 100644 internal/tui/components/chat/todos/todos.go delete mode 100644 internal/tui/components/completions/completions.go delete mode 100644 internal/tui/components/completions/keys.go delete mode 100644 internal/tui/components/core/core.go delete mode 100644 internal/tui/components/core/layout/layout.go delete mode 100644 internal/tui/components/core/status/status.go delete mode 100644 internal/tui/components/core/status_test.go delete mode 100644 internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/Default.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/LongDescription.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/NoIcon.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/WithColors.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden delete mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden delete mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden delete mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden delete mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden delete mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden delete mode 100644 internal/tui/components/dialogs/commands/arguments.go delete mode 100644 internal/tui/components/dialogs/commands/commands.go delete mode 100644 internal/tui/components/dialogs/commands/keys.go delete mode 100644 internal/tui/components/dialogs/copilot/device_flow.go delete mode 100644 internal/tui/components/dialogs/dialogs.go delete mode 100644 internal/tui/components/dialogs/filepicker/filepicker.go delete mode 100644 internal/tui/components/dialogs/filepicker/keys.go delete mode 100644 internal/tui/components/dialogs/hyper/device_flow.go delete mode 100644 internal/tui/components/dialogs/keys.go delete mode 100644 internal/tui/components/dialogs/models/apikey.go delete mode 100644 internal/tui/components/dialogs/models/keys.go delete mode 100644 internal/tui/components/dialogs/models/list.go delete mode 100644 internal/tui/components/dialogs/models/list_recent_test.go delete mode 100644 internal/tui/components/dialogs/models/models.go delete mode 100644 internal/tui/components/dialogs/permissions/keys.go delete mode 100644 internal/tui/components/dialogs/permissions/permissions.go delete mode 100644 internal/tui/components/dialogs/quit/keys.go delete mode 100644 internal/tui/components/dialogs/quit/quit.go delete mode 100644 internal/tui/components/dialogs/reasoning/reasoning.go delete mode 100644 internal/tui/components/dialogs/sessions/keys.go delete mode 100644 internal/tui/components/dialogs/sessions/sessions.go delete mode 100644 internal/tui/components/files/files.go delete mode 100644 internal/tui/components/image/image.go delete mode 100644 internal/tui/components/image/load.go delete mode 100644 internal/tui/components/logo/logo.go delete mode 100644 internal/tui/components/logo/rand.go delete mode 100644 internal/tui/components/lsp/lsp.go delete mode 100644 internal/tui/components/mcp/mcp.go delete mode 100644 internal/tui/exp/list/filterable.go delete mode 100644 internal/tui/exp/list/filterable_group.go delete mode 100644 internal/tui/exp/list/filterable_test.go delete mode 100644 internal/tui/exp/list/grouped.go delete mode 100644 internal/tui/exp/list/items.go delete mode 100644 internal/tui/exp/list/keys.go delete mode 100644 internal/tui/exp/list/list.go delete mode 100644 internal/tui/exp/list/list_test.go delete mode 100644 internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden delete mode 100644 internal/tui/highlight/highlight.go delete mode 100644 internal/tui/keys.go delete mode 100644 internal/tui/page/chat/chat.go delete mode 100644 internal/tui/page/chat/keys.go delete mode 100644 internal/tui/page/chat/pills.go delete mode 100644 internal/tui/page/page.go delete mode 100644 internal/tui/styles/charmtone.go delete mode 100644 internal/tui/styles/chroma.go delete mode 100644 internal/tui/styles/icons.go delete mode 100644 internal/tui/styles/markdown.go delete mode 100644 internal/tui/styles/theme.go delete mode 100644 internal/tui/tui.go delete mode 100644 internal/tui/util/shell.go delete mode 100644 internal/tui/util/util.go rename internal/{tui/exp => ui}/diffview/Taskfile.yaml (100%) rename internal/{tui/exp => ui}/diffview/chroma.go (100%) rename internal/{tui/exp => ui}/diffview/diffview.go (100%) rename internal/{tui/exp => ui}/diffview/diffview_test.go (99%) rename internal/{tui/exp => ui}/diffview/split.go (100%) rename internal/{tui/exp => ui}/diffview/style.go (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDefault.after (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDefault.before (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/Default/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewTabs/Split.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewTabs/Unified.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestLineBreakIssue.after (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestLineBreakIssue.before (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestMultipleHunks.after (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestMultipleHunks.before (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestNarrow.after (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestNarrow.before (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestTabs.after (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestTabs.before (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestUdiff/Unified.golden (100%) rename internal/{tui/exp => ui}/diffview/udiff_test.go (100%) rename internal/{tui/exp => ui}/diffview/util.go (100%) rename internal/{tui/exp => ui}/diffview/util_test.go (100%) create mode 100644 internal/ui/model/filter.go rename internal/{uiutil/uiutil.go => ui/util/util.go} (90%) delete mode 100644 internal/uicmd/uicmd.go diff --git a/go.mod b/go.mod index 2358911b7f6c3633b82b14e589c5db14c02d15d6..34af0a4796c028995a22639695f1c3d03baf6059 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,6 @@ require ( github.com/PuerkitoBio/goquery v1.11.0 github.com/alecthomas/chroma/v2 v2.23.1 github.com/atotto/clipboard v0.1.4 - github.com/aymanbagabas/go-nativeclipboard v0.1.2 github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charlievieth/fastwalk v1.0.14 @@ -36,7 +35,6 @@ require ( 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 github.com/dustin/go-humanize v1.0.1 github.com/google/uuid v1.6.0 @@ -46,9 +44,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 github.com/mattn/go-isatty v0.0.20 github.com/modelcontextprotocol/go-sdk v1.2.0 - github.com/muesli/termenv v0.16.0 github.com/ncruces/go-sqlite3 v0.30.5 - 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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -59,13 +55,10 @@ require ( github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sahilm/fuzzy v0.1.1 github.com/spf13/cobra v1.10.2 - github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c - github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/zeebo/xxh3 v1.1.0 - golang.org/x/mod v0.32.0 golang.org/x/net v0.49.0 golang.org/x/sync v0.19.0 golang.org/x/text v0.33.0 @@ -100,7 +93,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/smithy-go v1.24.0 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 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 @@ -111,9 +103,7 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/stringish v0.1.1 // 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 - github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect @@ -180,6 +170,7 @@ require ( golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/image v0.34.0 // indirect + golang.org/x/mod v0.32.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect diff --git a/go.sum b/go.sum index 91d0707fd0a5d50c4d64a8c68b606747b743f4c0..3d63891c57c31aa01ccaeacbaf55af1ab79fe45d 100644 --- a/go.sum +++ b/go.sum @@ -80,10 +80,6 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= -github.com/aymanbagabas/go-nativeclipboard v0.1.2 h1:Z2iVRWQ4IynMLWM6a+lWH2Nk5gPyEtPRMuBIyZ2dECM= -github.com/aymanbagabas/go-nativeclipboard v0.1.2/go.mod h1:BVJhN7hs5DieCzUB2Atf4Yk9Y9kFe62E95+gOjpJq6Q= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -148,18 +144,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= -github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs= -github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= -github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg= -github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA= -github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= @@ -275,16 +265,12 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE= github.com/ncruces/go-sqlite3 v0.30.5/go.mod h1:0I0JFflTKzfs3Ogfv8erP7CCoV/Z8uxigVDNOR0AQ5E= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8= @@ -331,10 +317,6 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= -github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= -github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= -github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= diff --git a/internal/app/app.go b/internal/app/app.go index 219b66f3cb79abcb6f004d08a6dc07bd539198ec..f0cabfa534a58401280fb5e9b973aa6f5a9d91c9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -33,8 +33,8 @@ import ( "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/shell" - "github.com/charmbracelet/crush/internal/tui/components/anim" - "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/update" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/x/ansi" @@ -160,7 +160,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, progress = app.config.Options.Progress == nil || *app.config.Options.Progress if !hideSpinner && stderrTTY { - t := styles.CurrentTheme() + t := styles.DefaultStyles() // Detect background color to set the appropriate color for the // spinner's 'Generating...' text. Without this, that text would be diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 727e4741dbfc607161e425c6b597ed7e28723a1b..c6dac2e7801779e359c939e7d595323a8ac22e49 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -20,7 +20,6 @@ import ( "github.com/charmbracelet/crush/internal/db" "github.com/charmbracelet/crush/internal/event" "github.com/charmbracelet/crush/internal/projects" - "github.com/charmbracelet/crush/internal/tui" "github.com/charmbracelet/crush/internal/ui/common" ui "github.com/charmbracelet/crush/internal/ui/model" "github.com/charmbracelet/crush/internal/version" @@ -28,14 +27,10 @@ import ( uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/charmtone" - xstrings "github.com/charmbracelet/x/exp/strings" "github.com/charmbracelet/x/term" "github.com/spf13/cobra" ) -// kittyTerminals defines terminals supporting querying capabilities. -var kittyTerminals = []string{"alacritty", "ghostty", "kitty", "rio", "wezterm"} - func init() { rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory") rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory") @@ -93,27 +88,15 @@ 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 - } + com := common.DefaultCommon(app) + model := ui.New(com) - var model tea.Model - if newUI { - slog.Info("New UI in control!") - com := common.DefaultCommon(app) - ui := ui.New(com) - model = ui - } else { - ui := tui.New(app) - ui.QueryVersion = shouldQueryCapabilities(env) - model = ui - } program := tea.NewProgram( model, tea.WithEnvironment(env), tea.WithContext(cmd.Context()), - tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state + tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state + ) go app.Subscribe(program) if _, err := program.Run(); err != nil { @@ -313,18 +296,3 @@ 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") - 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/cmd/root_test.go b/internal/cmd/root_test.go deleted file mode 100644 index 8b92f04c4ab7b120985505716e6200cd1845d295..0000000000000000000000000000000000000000 --- a/internal/cmd/root_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package cmd - -import ( - "strings" - "testing" - - uv "github.com/charmbracelet/ultraviolet" - xstrings "github.com/charmbracelet/x/exp/strings" - "github.com/stretchr/testify/require" -) - -type mockEnviron []string - -func (m mockEnviron) Getenv(key string) string { - v, _ := m.LookupEnv(key) - return v -} - -func (m mockEnviron) LookupEnv(key string) (string, bool) { - for _, env := range m { - kv := strings.SplitN(env, "=", 2) - if len(kv) == 2 && kv[0] == key { - return kv[1], true - } - } - return "", false -} - -func (m mockEnviron) ExpandEnv(s string) string { - return s // Not implemented for tests -} - -func (m mockEnviron) Slice() []string { - return []string(m) -} - -func TestShouldQueryImageCapabilities(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - env mockEnviron - want bool - }{ - { - name: "kitty terminal", - env: mockEnviron{"TERM=xterm-kitty"}, - want: true, - }, - { - name: "wezterm terminal", - env: mockEnviron{"TERM=xterm-256color"}, - want: true, - }, - { - name: "wezterm with WEZTERM env", - env: mockEnviron{"TERM=xterm-256color", "WEZTERM_EXECUTABLE=/Applications/WezTerm.app/Contents/MacOS/wezterm-gui"}, - want: true, // Not detected via TERM, only via stringext.ContainsAny which checks TERM - }, - { - name: "Apple Terminal", - env: mockEnviron{"TERM_PROGRAM=Apple_Terminal", "TERM=xterm-256color"}, - want: false, - }, - { - name: "alacritty", - env: mockEnviron{"TERM=alacritty"}, - want: true, - }, - { - name: "ghostty", - env: mockEnviron{"TERM=xterm-ghostty"}, - want: true, - }, - { - name: "rio", - env: mockEnviron{"TERM=rio"}, - want: true, - }, - { - name: "wezterm (detected via TERM)", - env: mockEnviron{"TERM=wezterm"}, - want: true, - }, - { - name: "SSH session", - env: mockEnviron{"SSH_TTY=/dev/pts/0", "TERM=xterm-256color"}, - want: false, - }, - { - name: "generic terminal", - env: mockEnviron{"TERM=xterm-256color"}, - want: true, - }, - { - name: "kitty over SSH", - env: mockEnviron{"SSH_TTY=/dev/pts/0", "TERM=xterm-kitty"}, - want: true, - }, - { - name: "Apple Terminal with kitty TERM (should still be false due to TERM_PROGRAM)", - env: mockEnviron{"TERM_PROGRAM=Apple_Terminal", "TERM=xterm-kitty"}, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := shouldQueryCapabilities(uv.Environ(tt.env)) - require.Equal(t, tt.want, got, "shouldQueryImageCapabilities() = %v, want %v", got, tt.want) - }) - } -} - -// This is a helper to test the underlying logic of stringext.ContainsAny -// which is used by shouldQueryImageCapabilities -func TestStringextContainsAny(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - s string - substr []string - want bool - }{ - { - name: "kitty in TERM", - s: "xterm-kitty", - substr: kittyTerminals, - want: true, - }, - { - name: "wezterm in TERM", - s: "wezterm", - substr: kittyTerminals, - want: true, - }, - { - name: "alacritty in TERM", - s: "alacritty", - substr: kittyTerminals, - want: true, - }, - { - name: "generic terminal not in list", - s: "xterm-256color", - substr: kittyTerminals, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := xstrings.ContainsAnyOf(tt.s, tt.substr...) - require.Equal(t, tt.want, got) - }) - } -} diff --git a/internal/format/spinner.go b/internal/format/spinner.go index 53d48dbb2831df8b6145f762884ee506c2f4ce0a..0a66ce8d886065aecada4999cabafea2f051a2b4 100644 --- a/internal/format/spinner.go +++ b/internal/format/spinner.go @@ -7,7 +7,7 @@ import ( "os" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/components/anim" + "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/x/ansi" ) @@ -22,8 +22,8 @@ type model struct { anim *anim.Anim } -func (m model) Init() tea.Cmd { return m.anim.Init() } -func (m model) View() tea.View { return tea.NewView(m.anim.View()) } +func (m model) Init() tea.Cmd { return m.anim.Start() } +func (m model) View() tea.View { return tea.NewView(m.anim.Render()) } // Update implements tea.Model. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -34,10 +34,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cancel() return m, tea.Quit } + case anim.StepMsg: + cmd := m.anim.Animate(msg) + return m, cmd } - mm, cmd := m.anim.Update(msg) - m.anim = mm.(*anim.Anim) - return m, cmd + return m, nil } // NewSpinner creates a new spinner with the given message diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go deleted file mode 100644 index 1ffa8074b09afb201a4238c848f3d289450173ce..0000000000000000000000000000000000000000 --- a/internal/tui/components/anim/anim.go +++ /dev/null @@ -1,447 +0,0 @@ -// Package anim provides an animated spinner. -package anim - -import ( - "fmt" - "image/color" - "math/rand/v2" - "strings" - "sync/atomic" - "time" - - "github.com/zeebo/xxh3" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/lucasb-eyer/go-colorful" - - "github.com/charmbracelet/crush/internal/csync" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const ( - fps = 20 - initialChar = '.' - labelGap = " " - labelGapWidth = 1 - - // Periods of ellipsis animation speed in steps. - // - // If the FPS is 20 (50 milliseconds) this means that the ellipsis will - // change every 8 frames (400 milliseconds). - ellipsisAnimSpeed = 8 - - // The maximum amount of time that can pass before a character appears. - // This is used to create a staggered entrance effect. - maxBirthOffset = time.Second - - // Number of frames to prerender for the animation. After this number - // of frames, the animation will loop. This only applies when color - // cycling is disabled. - prerenderedFrames = 10 - - // Default number of cycling chars. - defaultNumCyclingChars = 10 -) - -// Default colors for gradient. -var ( - defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff} - defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff} - defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff} -) - -var ( - availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_") - ellipsisFrames = []string{".", "..", "...", ""} -) - -// Internal ID management. Used during animating to ensure that frame messages -// are received only by spinner components that sent them. -var lastID int64 - -func nextID() int { - return int(atomic.AddInt64(&lastID, 1)) -} - -// Cache for expensive animation calculations -type animCache struct { - initialFrames [][]string - cyclingFrames [][]string - width int - labelWidth int - label []string - ellipsisFrames []string -} - -var animCacheMap = csync.NewMap[string, *animCache]() - -// settingsHash creates a hash key for the settings to use for caching -func settingsHash(opts Settings) string { - h := xxh3.New() - fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t", - opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors) - return fmt.Sprintf("%x", h.Sum(nil)) -} - -// StepMsg is a message type used to trigger the next step in the animation. -type StepMsg struct{ id int } - -// Settings defines settings for the animation. -type Settings struct { - Size int - Label string - LabelColor color.Color - GradColorA color.Color - GradColorB color.Color - CycleColors bool -} - -// Default settings. -const () - -// Anim is a Bubble for an animated spinner. -type Anim struct { - width int - cyclingCharWidth int - label *csync.Slice[string] - labelWidth int - labelColor color.Color - startTime time.Time - birthOffsets []time.Duration - initialFrames [][]string // frames for the initial characters - initialized atomic.Bool - cyclingFrames [][]string // frames for the cycling characters - step atomic.Int64 // current main frame step - ellipsisStep atomic.Int64 // current ellipsis frame step - ellipsisFrames *csync.Slice[string] // ellipsis animation frames - id int -} - -// New creates a new Anim instance with the specified width and label. -func New(opts Settings) *Anim { - a := &Anim{} - // Validate settings. - if opts.Size < 1 { - opts.Size = defaultNumCyclingChars - } - if colorIsUnset(opts.GradColorA) { - opts.GradColorA = defaultGradColorA - } - if colorIsUnset(opts.GradColorB) { - opts.GradColorB = defaultGradColorB - } - if colorIsUnset(opts.LabelColor) { - opts.LabelColor = defaultLabelColor - } - - a.id = nextID() - a.startTime = time.Now() - a.cyclingCharWidth = opts.Size - a.labelColor = opts.LabelColor - - // Check cache first - cacheKey := settingsHash(opts) - cached, exists := animCacheMap.Get(cacheKey) - - if exists { - // Use cached values - a.width = cached.width - a.labelWidth = cached.labelWidth - a.label = csync.NewSliceFrom(cached.label) - a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames) - a.initialFrames = cached.initialFrames - a.cyclingFrames = cached.cyclingFrames - } else { - // Generate new values and cache them - a.labelWidth = lipgloss.Width(opts.Label) - - // Total width of anim, in cells. - a.width = opts.Size - if opts.Label != "" { - a.width += labelGapWidth + lipgloss.Width(opts.Label) - } - - // Render the label - a.renderLabel(opts.Label) - - // Pre-generate gradient. - var ramp []color.Color - numFrames := prerenderedFrames - if opts.CycleColors { - ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB) - numFrames = a.width * 2 - } else { - ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB) - } - - // Pre-render initial characters. - a.initialFrames = make([][]string, numFrames) - offset := 0 - for i := range a.initialFrames { - a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth) - for j := range a.initialFrames[i] { - if j+offset >= len(ramp) { - continue // skip if we run out of colors - } - - var c color.Color - if j <= a.cyclingCharWidth { - c = ramp[j+offset] - } else { - c = opts.LabelColor - } - - // Also prerender the initial character with Lip Gloss to avoid - // processing in the render loop. - a.initialFrames[i][j] = lipgloss.NewStyle(). - Foreground(c). - Render(string(initialChar)) - } - if opts.CycleColors { - offset++ - } - } - - // Prerender scrambled rune frames for the animation. - a.cyclingFrames = make([][]string, numFrames) - offset = 0 - for i := range a.cyclingFrames { - a.cyclingFrames[i] = make([]string, a.width) - for j := range a.cyclingFrames[i] { - if j+offset >= len(ramp) { - continue // skip if we run out of colors - } - - // Also prerender the color with Lip Gloss here to avoid processing - // in the render loop. - r := availableRunes[rand.IntN(len(availableRunes))] - a.cyclingFrames[i][j] = lipgloss.NewStyle(). - Foreground(ramp[j+offset]). - Render(string(r)) - } - if opts.CycleColors { - offset++ - } - } - - // Cache the results - labelSlice := make([]string, a.label.Len()) - for i, v := range a.label.Seq2() { - labelSlice[i] = v - } - ellipsisSlice := make([]string, a.ellipsisFrames.Len()) - for i, v := range a.ellipsisFrames.Seq2() { - ellipsisSlice[i] = v - } - cached = &animCache{ - initialFrames: a.initialFrames, - cyclingFrames: a.cyclingFrames, - width: a.width, - labelWidth: a.labelWidth, - label: labelSlice, - ellipsisFrames: ellipsisSlice, - } - animCacheMap.Set(cacheKey, cached) - } - - // Random assign a birth to each character for a stagged entrance effect. - a.birthOffsets = make([]time.Duration, a.width) - for i := range a.birthOffsets { - a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond - } - - return a -} - -// SetLabel updates the label text and re-renders it. -func (a *Anim) SetLabel(newLabel string) { - a.labelWidth = lipgloss.Width(newLabel) - - // Update total width - a.width = a.cyclingCharWidth - if newLabel != "" { - a.width += labelGapWidth + a.labelWidth - } - - // Re-render the label - a.renderLabel(newLabel) -} - -// renderLabel renders the label with the current label color. -func (a *Anim) renderLabel(label string) { - if a.labelWidth > 0 { - // Pre-render the label. - labelRunes := []rune(label) - a.label = csync.NewSlice[string]() - for i := range labelRunes { - rendered := lipgloss.NewStyle(). - Foreground(a.labelColor). - Render(string(labelRunes[i])) - a.label.Append(rendered) - } - - // Pre-render the ellipsis frames which come after the label. - a.ellipsisFrames = csync.NewSlice[string]() - for _, frame := range ellipsisFrames { - rendered := lipgloss.NewStyle(). - Foreground(a.labelColor). - Render(frame) - a.ellipsisFrames.Append(rendered) - } - } else { - a.label = csync.NewSlice[string]() - a.ellipsisFrames = csync.NewSlice[string]() - } -} - -// Width returns the total width of the animation. -func (a *Anim) Width() (w int) { - w = a.width - if a.labelWidth > 0 { - w += labelGapWidth + a.labelWidth - - var widestEllipsisFrame int - for _, f := range ellipsisFrames { - fw := lipgloss.Width(f) - if fw > widestEllipsisFrame { - widestEllipsisFrame = fw - } - } - w += widestEllipsisFrame - } - return w -} - -// Init starts the animation. -func (a *Anim) Init() tea.Cmd { - return a.Step() -} - -// Update processes animation steps (or not). -func (a *Anim) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case StepMsg: - if msg.id != a.id { - // Reject messages that are not for this instance. - return a, nil - } - - step := a.step.Add(1) - if int(step) >= len(a.cyclingFrames) { - a.step.Store(0) - } - - if a.initialized.Load() && a.labelWidth > 0 { - // Manage the ellipsis animation. - ellipsisStep := a.ellipsisStep.Add(1) - if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) { - a.ellipsisStep.Store(0) - } - } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset { - a.initialized.Store(true) - } - return a, a.Step() - default: - return a, nil - } -} - -// View renders the current state of the animation. -func (a *Anim) View() string { - var b strings.Builder - step := int(a.step.Load()) - for i := range a.width { - switch { - case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]: - // Birth offset not reached: render initial character. - b.WriteString(a.initialFrames[step][i]) - case i < a.cyclingCharWidth: - // Render a cycling character. - b.WriteString(a.cyclingFrames[step][i]) - case i == a.cyclingCharWidth: - // Render label gap. - b.WriteString(labelGap) - case i > a.cyclingCharWidth: - // Label. - if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok { - b.WriteString(labelChar) - } - } - } - // Render animated ellipsis at the end of the label if all characters - // have been initialized. - if a.initialized.Load() && a.labelWidth > 0 { - ellipsisStep := int(a.ellipsisStep.Load()) - if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok { - b.WriteString(ellipsisFrame) - } - } - - return b.String() -} - -// Step is a command that triggers the next step in the animation. -func (a *Anim) Step() tea.Cmd { - return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg { - return StepMsg{id: a.id} - }) -} - -// makeGradientRamp() returns a slice of colors blended between the given keys. -// Blending is done as Hcl to stay in gamut. -func makeGradientRamp(size int, stops ...color.Color) []color.Color { - if len(stops) < 2 { - return nil - } - - points := make([]colorful.Color, len(stops)) - for i, k := range stops { - points[i], _ = colorful.MakeColor(k) - } - - numSegments := len(stops) - 1 - if numSegments == 0 { - return nil - } - blended := make([]color.Color, 0, size) - - // Calculate how many colors each segment should have. - segmentSizes := make([]int, numSegments) - baseSize := size / numSegments - remainder := size % numSegments - - // Distribute the remainder across segments. - for i := range numSegments { - segmentSizes[i] = baseSize - if i < remainder { - segmentSizes[i]++ - } - } - - // Generate colors for each segment. - for i := range numSegments { - c1 := points[i] - c2 := points[i+1] - segmentSize := segmentSizes[i] - - for j := range segmentSize { - if segmentSize == 0 { - continue - } - t := float64(j) / float64(segmentSize) - c := c1.BlendHcl(c2, t) - blended = append(blended, c) - } - } - - return blended -} - -func colorIsUnset(c color.Color) bool { - if c == nil { - return true - } - _, _, _, a := c.RGBA() - return a == 0 -} diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go deleted file mode 100644 index 036c8262d2b0d8419bf89b64afd922767b6be12a..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/chat.go +++ /dev/null @@ -1,782 +0,0 @@ -package chat - -import ( - "context" - "time" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "github.com/atotto/clipboard" - "github.com/charmbracelet/crush/internal/agent" - "github.com/charmbracelet/crush/internal/agent/tools" - "github.com/charmbracelet/crush/internal/app" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/crush/internal/pubsub" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/components/chat/messages" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -type SendMsg struct { - Text string - Attachments []message.Attachment -} - -type SessionSelectedMsg = session.Session - -type SessionClearedMsg struct{} - -type SelectionCopyMsg struct { - clickCount int - endSelection bool - x, y int -} - -const ( - NotFound = -1 -) - -// MessageListCmp represents a component that displays a list of chat messages -// with support for real-time updates and session management. -type MessageListCmp interface { - util.Model - layout.Sizeable - layout.Focusable - layout.Help - - SetSession(session.Session) tea.Cmd - GoToBottom() tea.Cmd - GetSelectedText() string - CopySelectedText(bool) tea.Cmd -} - -// messageListCmp implements MessageListCmp, providing a virtualized list -// of chat messages with support for tool calls, real-time updates, and -// session switching. -type messageListCmp struct { - app *app.App - width, height int - session session.Session - listCmp list.List[list.Item] - previousSelected string // Last selected item index for restoring focus - - lastUserMessageTime int64 - defaultListKeyMap list.KeyMap - - // Click tracking for double/triple click detection - lastClickTime time.Time - lastClickX int - lastClickY int - clickCount int -} - -// New creates a new message list component with custom keybindings -// and reverse ordering (newest messages at bottom). -func New(app *app.App) MessageListCmp { - defaultListKeyMap := list.DefaultKeyMap() - listCmp := list.New( - []list.Item{}, - list.WithGap(1), - list.WithDirectionBackward(), - list.WithFocus(false), - list.WithKeyMap(defaultListKeyMap), - list.WithEnableMouse(), - ) - return &messageListCmp{ - app: app, - listCmp: listCmp, - previousSelected: "", - defaultListKeyMap: defaultListKeyMap, - } -} - -// Init initializes the component. -func (m *messageListCmp) Init() tea.Cmd { - return m.listCmp.Init() -} - -// Update handles incoming messages and updates the component state. -func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.KeyPressMsg: - if m.listCmp.IsFocused() && m.listCmp.HasSelection() { - switch { - case key.Matches(msg, messages.CopyKey): - cmds = append(cmds, m.CopySelectedText(true)) - return m, tea.Batch(cmds...) - case key.Matches(msg, messages.ClearSelectionKey): - cmds = append(cmds, m.SelectionClear()) - return m, tea.Batch(cmds...) - } - } - case tea.MouseClickMsg: - x := msg.X - 1 // Adjust for padding - y := msg.Y - 1 // Adjust for padding - if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { - return m, nil // Ignore clicks outside the component - } - if msg.Button == tea.MouseLeft { - cmds = append(cmds, m.handleMouseClick(x, y)) - return m, tea.Batch(cmds...) - } - return m, tea.Batch(cmds...) - case tea.MouseMotionMsg: - x := msg.X - 1 // Adjust for padding - y := msg.Y - 1 // Adjust for padding - if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { - if y < 0 { - cmds = append(cmds, m.listCmp.MoveUp(1)) - return m, tea.Batch(cmds...) - } - if y >= m.height-1 { - cmds = append(cmds, m.listCmp.MoveDown(1)) - return m, tea.Batch(cmds...) - } - return m, nil // Ignore clicks outside the component - } - if msg.Button == tea.MouseLeft { - m.listCmp.EndSelection(x, y) - } - return m, tea.Batch(cmds...) - case tea.MouseReleaseMsg: - x := msg.X - 1 // Adjust for padding - y := msg.Y - 1 // Adjust for padding - if msg.Button == tea.MouseLeft { - clickCount := m.clickCount - if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { - tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg { - return SelectionCopyMsg{ - clickCount: clickCount, - endSelection: false, - } - }) - - cmds = append(cmds, tick) - return m, tea.Batch(cmds...) - } - tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg { - return SelectionCopyMsg{ - clickCount: clickCount, - endSelection: true, - x: x, - y: y, - } - }) - cmds = append(cmds, tick) - return m, tea.Batch(cmds...) - } - return m, nil - case SelectionCopyMsg: - if msg.clickCount == m.clickCount && time.Since(m.lastClickTime) >= doubleClickThreshold { - // If the click count matches and within threshold, copy selected text - if msg.endSelection { - m.listCmp.EndSelection(msg.x, msg.y) - } - m.listCmp.SelectionStop() - cmds = append(cmds, m.CopySelectedText(true)) - return m, tea.Batch(cmds...) - } - case pubsub.Event[permission.PermissionNotification]: - cmds = append(cmds, m.handlePermissionRequest(msg.Payload)) - return m, tea.Batch(cmds...) - case SessionSelectedMsg: - if msg.ID != m.session.ID { - cmds = append(cmds, m.SetSession(msg)) - } - return m, tea.Batch(cmds...) - case SessionClearedMsg: - m.session = session.Session{} - cmds = append(cmds, m.listCmp.SetItems([]list.Item{})) - return m, tea.Batch(cmds...) - - case pubsub.Event[message.Message]: - cmds = append(cmds, m.handleMessageEvent(msg)) - return m, tea.Batch(cmds...) - - case tea.MouseWheelMsg: - u, cmd := m.listCmp.Update(msg) - m.listCmp = u.(list.List[list.Item]) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) - } - - u, cmd := m.listCmp.Update(msg) - m.listCmp = u.(list.List[list.Item]) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) -} - -// View renders the message list or an initial screen if empty. -func (m *messageListCmp) View() string { - t := styles.CurrentTheme() - return t.S().Base. - Padding(1, 1, 0, 1). - Width(m.width). - Height(m.height). - Render(m.listCmp.View()) -} - -func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd { - items := m.listCmp.Items() - if toolCallIndex := m.findToolCallByID(items, permission.ToolCallID); toolCallIndex != NotFound { - toolCall := items[toolCallIndex].(messages.ToolCallCmp) - toolCall.SetPermissionRequested() - if permission.Granted { - toolCall.SetPermissionGranted() - } - m.listCmp.UpdateItem(toolCall.ID(), toolCall) - } - return nil -} - -// handleChildSession handles messages from child sessions (agent tools). -func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd { - var cmds []tea.Cmd - if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 { - return nil - } - - // Check if this is an agent tool session and parse it - childSessionID := event.Payload.SessionID - parentMessageID, toolCallID, ok := m.app.Sessions.ParseAgentToolSessionID(childSessionID) - if !ok { - return nil - } - items := m.listCmp.Items() - toolCallInx := NotFound - var toolCall messages.ToolCallCmp - for i := len(items) - 1; i >= 0; i-- { - if msg, ok := items[i].(messages.ToolCallCmp); ok { - if msg.ParentMessageID() == parentMessageID && msg.GetToolCall().ID == toolCallID { - toolCallInx = i - toolCall = msg - } - } - } - if toolCallInx == NotFound { - return nil - } - nestedToolCalls := toolCall.GetNestedToolCalls() - for _, tc := range event.Payload.ToolCalls() { - found := false - for existingInx, existingTC := range nestedToolCalls { - if existingTC.GetToolCall().ID == tc.ID { - nestedToolCalls[existingInx].SetToolCall(tc) - found = true - break - } - } - if !found { - nestedCall := messages.NewToolCallCmp( - event.Payload.ID, - tc, - m.app.Permissions, - messages.WithToolCallNested(true), - ) - cmds = append(cmds, nestedCall.Init()) - nestedToolCalls = append( - nestedToolCalls, - nestedCall, - ) - } - } - for _, tr := range event.Payload.ToolResults() { - for nestedInx, nestedTC := range nestedToolCalls { - if nestedTC.GetToolCall().ID == tr.ToolCallID { - nestedToolCalls[nestedInx].SetToolResult(tr) - break - } - } - } - - toolCall.SetNestedToolCalls(nestedToolCalls) - m.listCmp.UpdateItem( - toolCall.ID(), - toolCall, - ) - return tea.Batch(cmds...) -} - -// handleMessageEvent processes different types of message events (created/updated). -func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd { - switch event.Type { - case pubsub.CreatedEvent: - if event.Payload.SessionID != m.session.ID { - return m.handleChildSession(event) - } - if m.messageExists(event.Payload.ID) { - return nil - } - return m.handleNewMessage(event.Payload) - case pubsub.DeletedEvent: - if event.Payload.SessionID != m.session.ID { - return nil - } - return m.handleDeleteMessage(event.Payload) - case pubsub.UpdatedEvent: - if event.Payload.SessionID != m.session.ID { - return m.handleChildSession(event) - } - switch event.Payload.Role { - case message.Assistant: - return m.handleUpdateAssistantMessage(event.Payload) - case message.Tool: - return m.handleToolMessage(event.Payload) - } - } - return nil -} - -// messageExists checks if a message with the given ID already exists in the list. -func (m *messageListCmp) messageExists(messageID string) bool { - items := m.listCmp.Items() - // Search backwards as new messages are more likely to be at the end - for i := len(items) - 1; i >= 0; i-- { - if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID { - return true - } - } - return false -} - -// handleDeleteMessage removes a message from the list. -func (m *messageListCmp) handleDeleteMessage(msg message.Message) tea.Cmd { - items := m.listCmp.Items() - for i := len(items) - 1; i >= 0; i-- { - if msgCmp, ok := items[i].(messages.MessageCmp); ok && msgCmp.GetMessage().ID == msg.ID { - m.listCmp.DeleteItem(items[i].ID()) - return nil - } - } - return nil -} - -// handleNewMessage routes new messages to appropriate handlers based on role. -func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd { - switch msg.Role { - case message.User: - return m.handleNewUserMessage(msg) - case message.Assistant: - return m.handleNewAssistantMessage(msg) - case message.Tool: - return m.handleToolMessage(msg) - } - return nil -} - -// handleNewUserMessage adds a new user message to the list and updates the timestamp. -func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd { - m.lastUserMessageTime = msg.CreatedAt - return m.listCmp.AppendItem(messages.NewMessageCmp(msg)) -} - -// handleToolMessage updates existing tool calls with their results. -func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd { - items := m.listCmp.Items() - for _, tr := range msg.ToolResults() { - if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound { - toolCall := items[toolCallIndex].(messages.ToolCallCmp) - toolCall.SetToolResult(tr) - m.listCmp.UpdateItem(toolCall.ID(), toolCall) - } - } - return nil -} - -// findToolCallByID searches for a tool call with the specified ID. -// Returns the index if found, NotFound otherwise. -func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int { - // Search backwards as tool calls are more likely to be recent - for i := len(items) - 1; i >= 0; i-- { - if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID { - return i - } - } - return NotFound -} - -// handleUpdateAssistantMessage processes updates to assistant messages, -// managing both message content and associated tool calls. -func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd { - var cmds []tea.Cmd - items := m.listCmp.Items() - - // Find existing assistant message and tool calls for this message - assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID) - - // Handle assistant message content - if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil { - cmds = append(cmds, cmd) - } - - // Handle tool calls - if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil { - cmds = append(cmds, cmd) - } - - return tea.Batch(cmds...) -} - -// findAssistantMessageAndToolCalls locates the assistant message and its tool calls. -func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) { - assistantIndex := NotFound - toolCalls := make(map[int]messages.ToolCallCmp) - - // Search backwards as messages are more likely to be at the end - for i := len(items) - 1; i >= 0; i-- { - item := items[i] - if asMsg, ok := item.(messages.MessageCmp); ok { - if asMsg.GetMessage().ID == messageID { - assistantIndex = i - } - } else if tc, ok := item.(messages.ToolCallCmp); ok { - if tc.ParentMessageID() == messageID { - toolCalls[i] = tc - } - } - } - - return assistantIndex, toolCalls -} - -// updateAssistantMessageContent updates or removes the assistant message based on content. -func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd { - if assistantIndex == NotFound { - return nil - } - - shouldShowMessage := m.shouldShowAssistantMessage(msg) - hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == "" - - var cmd tea.Cmd - if shouldShowMessage { - items := m.listCmp.Items() - uiMsg := items[assistantIndex].(messages.MessageCmp) - uiMsg.SetMessage(msg) - m.listCmp.UpdateItem( - items[assistantIndex].ID(), - uiMsg, - ) - if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { - m.listCmp.AppendItem( - messages.NewAssistantSection( - msg, - time.Unix(m.lastUserMessageTime, 0), - ), - ) - } - } else if hasToolCallsOnly { - items := m.listCmp.Items() - m.listCmp.DeleteItem(items[assistantIndex].ID()) - } - - return cmd -} - -// shouldShowAssistantMessage determines if an assistant message should be displayed. -func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool { - return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.ReasoningContent().Thinking != "" || msg.IsThinking() -} - -// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones. -func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd { - var cmds []tea.Cmd - - for _, tc := range msg.ToolCalls() { - if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil { - cmds = append(cmds, cmd) - } - } - - return tea.Batch(cmds...) -} - -// updateOrAddToolCall updates an existing tool call or adds a new one. -func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd { - // Try to find existing tool call - for _, existingTC := range existingToolCalls { - if tc.ID == existingTC.GetToolCall().ID { - existingTC.SetToolCall(tc) - if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled { - existingTC.SetCancelled() - } - m.listCmp.UpdateItem(tc.ID, existingTC) - return nil - } - } - - // Add new tool call if not found - return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions)) -} - -// handleNewAssistantMessage processes new assistant messages and their tool calls. -func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd { - var cmds []tea.Cmd - - // Add assistant message if it should be displayed - if m.shouldShowAssistantMessage(msg) { - cmd := m.listCmp.AppendItem( - messages.NewMessageCmp( - msg, - ), - ) - cmds = append(cmds, cmd) - } - - // Add tool calls - for _, tc := range msg.ToolCalls() { - cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions)) - cmds = append(cmds, cmd) - } - - return tea.Batch(cmds...) -} - -// SetSession loads and displays messages for a new session. -func (m *messageListCmp) SetSession(session session.Session) tea.Cmd { - if m.session.ID == session.ID { - return nil - } - - m.session = session - sessionMessages, err := m.app.Messages.List(context.Background(), session.ID) - if err != nil { - return util.ReportError(err) - } - - if len(sessionMessages) == 0 { - return m.listCmp.SetItems([]list.Item{}) - } - - // Initialize with first message timestamp - m.lastUserMessageTime = sessionMessages[0].CreatedAt - - // Build tool result map for efficient lookup - toolResultMap := m.buildToolResultMap(sessionMessages) - - // Convert messages to UI components - uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap) - - return m.listCmp.SetItems(uiMessages) -} - -// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup. -func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult { - toolResultMap := make(map[string]message.ToolResult) - for _, msg := range messages { - for _, tr := range msg.ToolResults() { - toolResultMap[tr.ToolCallID] = tr - } - } - return toolResultMap -} - -// convertMessagesToUI converts database messages to UI components. -func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item { - uiMessages := make([]list.Item, 0) - - for _, msg := range sessionMessages { - switch msg.Role { - case message.User: - m.lastUserMessageTime = msg.CreatedAt - uiMessages = append(uiMessages, messages.NewMessageCmp(msg)) - case message.Assistant: - uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...) - if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { - uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0))) - } - } - } - - return uiMessages -} - -// convertAssistantMessage converts an assistant message and its tool calls to UI components. -func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item { - var uiMessages []list.Item - - // Add assistant message if it should be displayed - if m.shouldShowAssistantMessage(msg) { - uiMessages = append( - uiMessages, - messages.NewMessageCmp( - msg, - ), - ) - } - - // Add tool calls with their results and status - for _, tc := range msg.ToolCalls() { - options := m.buildToolCallOptions(tc, msg, toolResultMap) - uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...)) - // If this tool call is the agent tool or agentic fetch, fetch nested tool calls - if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName { - agentToolSessionID := m.app.Sessions.CreateAgentToolSessionID(msg.ID, tc.ID) - nestedMessages, _ := m.app.Messages.List(context.Background(), agentToolSessionID) - nestedToolResultMap := m.buildToolResultMap(nestedMessages) - nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap) - nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages)) - for _, nestedMsg := range nestedUIMessages { - if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok { - toolCall.SetIsNested(true) - nestedToolCalls = append(nestedToolCalls, toolCall) - } - } - uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls) - } - } - - return uiMessages -} - -// buildToolCallOptions creates options for tool call components based on results and status. -func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption { - var options []messages.ToolCallOption - - // Add tool result if available - if tr, ok := toolResultMap[tc.ID]; ok { - options = append(options, messages.WithToolCallResult(tr)) - } - - // Add cancelled status if applicable - if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled { - options = append(options, messages.WithToolCallCancelled()) - } - - return options -} - -// GetSize returns the current width and height of the component. -func (m *messageListCmp) GetSize() (int, int) { - return m.width, m.height -} - -// SetSize updates the component dimensions and propagates to the list component. -func (m *messageListCmp) SetSize(width int, height int) tea.Cmd { - m.width = width - m.height = height - return m.listCmp.SetSize(width-2, max(0, height-1)) // for padding -} - -// Blur implements MessageListCmp. -func (m *messageListCmp) Blur() tea.Cmd { - return m.listCmp.Blur() -} - -// Focus implements MessageListCmp. -func (m *messageListCmp) Focus() tea.Cmd { - return m.listCmp.Focus() -} - -// IsFocused implements MessageListCmp. -func (m *messageListCmp) IsFocused() bool { - return m.listCmp.IsFocused() -} - -func (m *messageListCmp) Bindings() []key.Binding { - return m.defaultListKeyMap.KeyBindings() -} - -func (m *messageListCmp) GoToBottom() tea.Cmd { - return m.listCmp.GoToBottom() -} - -const ( - doubleClickThreshold = 500 * time.Millisecond - clickTolerance = 2 // pixels -) - -// handleMouseClick handles mouse click events and detects double/triple clicks. -func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd { - now := time.Now() - - // Check if this is a potential multi-click - 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 - - switch m.clickCount { - case 1: - // Single click - start selection - m.listCmp.StartSelection(x, y) - case 2: - // Double click - select word - m.listCmp.SelectWord(x, y) - case 3: - // Triple click - select paragraph - m.listCmp.SelectParagraph(x, y) - m.clickCount = 0 // Reset after triple click - } - - return nil -} - -// SelectionClear clears the current selection in the list component. -func (m *messageListCmp) SelectionClear() tea.Cmd { - m.listCmp.SelectionClear() - m.previousSelected = "" - m.lastClickX, m.lastClickY = 0, 0 - m.lastClickTime = time.Time{} - m.clickCount = 0 - return nil -} - -// HasSelection checks if there is a selection in the list component. -func (m *messageListCmp) HasSelection() bool { - return m.listCmp.HasSelection() -} - -// GetSelectedText returns the currently selected text from the list component. -func (m *messageListCmp) GetSelectedText() string { - return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding -} - -// CopySelectedText copies the currently selected text to the clipboard. When -// clear is true, it clears the selection after copying. -func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd { - if !m.listCmp.HasSelection() { - return nil - } - - selectedText := m.GetSelectedText() - if selectedText == "" { - return util.ReportInfo("No text selected") - } - - cmds := []tea.Cmd{ - // We use both OSC 52 and native clipboard for compatibility with different - // terminal emulators and environments. - tea.SetClipboard(selectedText), - func() tea.Msg { - _ = clipboard.WriteAll(selectedText) - return nil - }, - util.ReportInfo("Selected text copied to clipboard"), - } - if clear { - cmds = append(cmds, m.SelectionClear()) - } - - return tea.Sequence(cmds...) -} - -// abs returns the absolute value of an integer. -func abs(x int) int { - if x < 0 { - return -x - } - return x -} diff --git a/internal/tui/components/chat/editor/clipboard.go b/internal/tui/components/chat/editor/clipboard.go deleted file mode 100644 index de4b95da3cab6069bf31f61b5fb9e2908f970c07..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/clipboard.go +++ /dev/null @@ -1,8 +0,0 @@ -package editor - -type clipboardFormat int - -const ( - clipboardFormatText clipboardFormat = iota - clipboardFormatImage -) diff --git a/internal/tui/components/chat/editor/clipboard_not_supported.go b/internal/tui/components/chat/editor/clipboard_not_supported.go deleted file mode 100644 index dfecc09dca05ca5d07dd1db109fe3178f6c357b8..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/clipboard_not_supported.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !(darwin || linux || windows) || arm || 386 || ios || android - -package editor - -func readClipboard(clipboardFormat) ([]byte, error) { - return nil, errClipboardPlatformUnsupported -} diff --git a/internal/tui/components/chat/editor/clipboard_supported.go b/internal/tui/components/chat/editor/clipboard_supported.go deleted file mode 100644 index 175a4b4ea4dfaea03916dc1012c313201f1846f8..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/clipboard_supported.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build (linux || darwin || windows) && !arm && !386 && !ios && !android - -package editor - -import "github.com/aymanbagabas/go-nativeclipboard" - -func readClipboard(f clipboardFormat) ([]byte, error) { - switch f { - case clipboardFormatText: - return nativeclipboard.Text.Read() - case clipboardFormatImage: - return nativeclipboard.Image.Read() - } - return nil, errClipboardUnknownFormat -} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go deleted file mode 100644 index 575c23114a9115209db7a2a02e642fe5f2246541..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/editor.go +++ /dev/null @@ -1,780 +0,0 @@ -package editor - -import ( - "context" - "fmt" - "math/rand" - "net/http" - "os" - "path/filepath" - "regexp" - "slices" - "strconv" - "strings" - "unicode" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textarea" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/app" - "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/completions" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/editor" -) - -var ( - errClipboardPlatformUnsupported = fmt.Errorf("clipboard operations are not supported on this platform") - errClipboardUnknownFormat = fmt.Errorf("unknown clipboard format") -) - -// If pasted text has more than 10 newlines, treat it as a file attachment. -const pasteLinesThreshold = 10 - -type Editor interface { - util.Model - layout.Sizeable - layout.Focusable - layout.Help - layout.Positional - - SetSession(session session.Session) tea.Cmd - IsCompletionsOpen() bool - HasAttachments() bool - IsEmpty() bool - Cursor() *tea.Cursor -} - -type FileCompletionItem struct { - Path string // The file path -} - -type editorCmp struct { - width int - height int - x, y int - app *app.App - session session.Session - sessionFileReads []string - textarea textarea.Model - attachments []message.Attachment - deleteMode bool - readyPlaceholder string - workingPlaceholder string - - keyMap EditorKeyMap - - // File path completions - currentQuery string - completionsStartIndex int - isCompletionsOpen bool -} - -var DeleteKeyMaps = DeleteAttachmentKeyMaps{ - AttachmentDeleteMode: key.NewBinding( - key.WithKeys("ctrl+r"), - key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), - ), - Escape: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel delete mode"), - ), - DeleteAllAttachments: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("ctrl+r+r", "delete all attachments"), - ), -} - -const maxFileResults = 25 - -type OpenEditorMsg struct { - Text string -} - -func (m *editorCmp) openEditor(value string) tea.Cmd { - tmpfile, err := os.CreateTemp("", "msg_*.md") - if err != nil { - return util.ReportError(err) - } - defer tmpfile.Close() //nolint:errcheck - if _, err := tmpfile.WriteString(value); err != nil { - return util.ReportError(err) - } - cmd, err := editor.Command( - "crush", - tmpfile.Name(), - editor.AtPosition( - m.textarea.Line()+1, - m.textarea.Column()+1, - ), - ) - if err != nil { - return util.ReportError(err) - } - return tea.ExecProcess(cmd, func(err error) tea.Msg { - if err != nil { - return util.ReportError(err) - } - content, err := os.ReadFile(tmpfile.Name()) - if err != nil { - return util.ReportError(err) - } - if len(content) == 0 { - return util.ReportWarn("Message is empty") - } - os.Remove(tmpfile.Name()) - return OpenEditorMsg{ - Text: strings.TrimSpace(string(content)), - } - }) -} - -func (m *editorCmp) Init() tea.Cmd { - return nil -} - -func (m *editorCmp) send() tea.Cmd { - value := m.textarea.Value() - value = strings.TrimSpace(value) - - switch value { - case "exit", "quit": - m.textarea.Reset() - return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()}) - } - - attachments := m.attachments - - if value == "" && !message.ContainsTextAttachment(attachments) { - return nil - } - - m.textarea.Reset() - m.attachments = nil - // Change the placeholder when sending a new message. - m.randomizePlaceholders() - - return tea.Batch( - util.CmdHandler(chat.SendMsg{ - Text: value, - Attachments: attachments, - }), - ) -} - -func (m *editorCmp) repositionCompletions() tea.Msg { - x, y := m.completionsPosition() - return completions.RepositionCompletionsMsg{X: x, Y: y} -} - -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: - m.attachments = append(m.attachments, msg.Attachment) - return m, nil - case completions.CompletionsOpenedMsg: - m.isCompletionsOpen = true - case completions.CompletionsClosedMsg: - m.isCompletionsOpen = false - m.currentQuery = "" - m.completionsStartIndex = 0 - case completions.SelectCompletionMsg: - if !m.isCompletionsOpen { - return m, nil - } - if item, ok := msg.Value.(FileCompletionItem); ok { - word := m.textarea.Word() - // If the selected item is a file, insert its path into the textarea - value := m.textarea.Value() - value = value[:m.completionsStartIndex] + // Remove the current query - item.Path + // Insert the file path - value[m.completionsStartIndex+len(word):] // Append the rest of the value - // XXX: This will always move the cursor to the end of the textarea. - m.textarea.SetValue(value) - m.textarea.MoveToEnd() - if !msg.Insert { - m.isCompletionsOpen = false - m.currentQuery = "" - m.completionsStartIndex = 0 - } - absPath, _ := filepath.Abs(item.Path) - - ctx := context.Background() - - // Skip attachment if file was already read and hasn't been modified. - 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 - } - m.attachments = append(m.attachments, message.Attachment{ - FilePath: item.Path, - FileName: filepath.Base(item.Path), - MimeType: mimeOf(content), - Content: content, - }) - } - - case commands.OpenExternalEditorMsg: - if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) { - return m, util.ReportWarn("Agent is working, please wait...") - } - return m, m.openEditor(m.textarea.Value()) - case OpenEditorMsg: - m.textarea.SetValue(msg.Text) - m.textarea.MoveToEnd() - case tea.PasteMsg: - if strings.Count(msg.Content, "\n") > pasteLinesThreshold { - content := []byte(msg.Content) - if len(content) > maxAttachmentSize { - return m, util.ReportWarn("Paste is too big (>5mb)") - } - name := fmt.Sprintf("paste_%d.txt", m.pasteIdx()) - mimeType := mimeOf(content) - attachment := message.Attachment{ - FileName: name, - FilePath: name, - MimeType: mimeType, - Content: content, - } - return m, util.CmdHandler(filepicker.FilePickedMsg{ - Attachment: attachment, - }) - } - - // Try to parse as a file path. - content, path, err := filepathToFile(msg.Content) - if err != nil { - // Not a file path, just update the textarea normally. - m.textarea, cmd = m.textarea.Update(msg) - return m, cmd - } - - if len(content) > maxAttachmentSize { - return m, util.ReportWarn("File is too big (>5mb)") - } - - mimeType := mimeOf(content) - attachment := message.Attachment{ - FilePath: path, - FileName: filepath.Base(path), - MimeType: mimeType, - Content: content, - } - if !attachment.IsText() && !attachment.IsImage() { - return m, util.ReportWarn("Invalid file content type: " + mimeType) - } - return m, util.CmdHandler(filepicker.FilePickedMsg{ - Attachment: attachment, - }) - - case commands.ToggleYoloModeMsg: - m.setEditorPrompt() - return m, nil - case tea.KeyPressMsg: - cur := m.textarea.Cursor() - curIdx := m.textarea.Width()*cur.Y + cur.X - switch { - // Open command palette when "/" is pressed on empty prompt - case msg.String() == "/" && m.IsEmpty(): - return m, util.CmdHandler(dialogs.OpenDialogMsg{ - Model: commands.NewCommandDialog(m.session.ID), - }) - // Completions - case msg.String() == "@" && !m.isCompletionsOpen && - // only show if beginning of prompt, or if previous char is a space or newline: - (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))): - m.isCompletionsOpen = true - m.currentQuery = "" - m.completionsStartIndex = curIdx - cmds = append(cmds, m.startCompletions) - case m.isCompletionsOpen && curIdx <= m.completionsStartIndex: - cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) - } - if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) { - m.deleteMode = true - return m, nil - } - if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode { - m.deleteMode = false - m.attachments = nil - return m, nil - } - rune := msg.Code - if m.deleteMode && unicode.IsDigit(rune) { - num := int(rune - '0') - m.deleteMode = false - if num < 10 && len(m.attachments) > num { - if num == 0 { - m.attachments = m.attachments[num+1:] - } else { - m.attachments = slices.Delete(m.attachments, num, num+1) - } - return m, nil - } - } - if key.Matches(msg, m.keyMap.OpenEditor) { - if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) { - return m, util.ReportWarn("Agent is working, please wait...") - } - return m, m.openEditor(m.textarea.Value()) - } - if key.Matches(msg, DeleteKeyMaps.Escape) { - m.deleteMode = false - return m, nil - } - if key.Matches(msg, m.keyMap.Newline) { - m.textarea.InsertRune('\n') - cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) - } - // Handle image paste from clipboard - if key.Matches(msg, m.keyMap.PasteImage) { - imageData, err := readClipboard(clipboardFormatImage) - - if err != nil || len(imageData) == 0 { - // If no image data found, try to get text data (could be file path) - var textData []byte - textData, err = readClipboard(clipboardFormatText) - if err != nil || len(textData) == 0 { - // If clipboard is empty, show a warning - return m, util.ReportWarn("No data found in clipboard. Note: Some terminals may not support reading image data from clipboard directly.") - } - - // Check if the text data is a file path - textStr := string(textData) - // First, try to interpret as a file path (existing functionality) - path := strings.ReplaceAll(textStr, "\\ ", " ") - path, err = filepath.Abs(strings.TrimSpace(path)) - if err == nil { - isAllowedType := false - for _, ext := range filepicker.AllowedTypes { - if strings.HasSuffix(path, ext) { - isAllowedType = true - break - } - } - if isAllowedType { - tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize) - if !tooBig { - content, err := os.ReadFile(path) - if err == nil { - mimeBufferSize := min(512, len(content)) - mimeType := http.DetectContentType(content[:mimeBufferSize]) - fileName := filepath.Base(path) - attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content} - return m, util.CmdHandler(filepicker.FilePickedMsg{ - Attachment: attachment, - }) - } - } - } - } - - // If not a valid file path, show a warning - return m, util.ReportWarn("No image found in clipboard") - } else { - // We have image data from the clipboard - // Create a temporary file to store the clipboard image data - tempFile, err := os.CreateTemp("", "clipboard_image_crush_*") - if err != nil { - return m, util.ReportError(err) - } - defer tempFile.Close() - - // Write clipboard content to the temporary file - _, err = tempFile.Write(imageData) - if err != nil { - return m, util.ReportError(err) - } - - // Determine the file extension based on the image data - mimeBufferSize := min(512, len(imageData)) - mimeType := http.DetectContentType(imageData[:mimeBufferSize]) - - // Create an attachment from the temporary file - fileName := filepath.Base(tempFile.Name()) - attachment := message.Attachment{ - FilePath: tempFile.Name(), - FileName: fileName, - MimeType: mimeType, - Content: imageData, - } - - return m, util.CmdHandler(filepicker.FilePickedMsg{ - Attachment: attachment, - }) - } - } - // Handle Enter key - if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) { - value := m.textarea.Value() - if strings.HasSuffix(value, "\\") { - // If the last character is a backslash, remove it and add a newline. - m.textarea.SetValue(strings.TrimSuffix(value, "\\")) - } else { - // Otherwise, send the message - return m, m.send() - } - } - } - - m.textarea, cmd = m.textarea.Update(msg) - cmds = append(cmds, cmd) - - if m.textarea.Focused() { - kp, ok := msg.(tea.KeyPressMsg) - if ok { - if kp.String() == "space" || m.textarea.Value() == "" { - m.isCompletionsOpen = false - m.currentQuery = "" - m.completionsStartIndex = 0 - cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) - } else { - word := m.textarea.Word() - if strings.HasPrefix(word, "@") { - // XXX: wont' work if editing in the middle of the field. - m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word) - m.currentQuery = word[1:] - x, y := m.completionsPosition() - x -= len(m.currentQuery) - m.isCompletionsOpen = true - cmds = append(cmds, - util.CmdHandler(completions.FilterCompletionsMsg{ - Query: m.currentQuery, - Reopen: m.isCompletionsOpen, - X: x, - Y: y, - }), - ) - } else if m.isCompletionsOpen { - m.isCompletionsOpen = false - m.currentQuery = "" - m.completionsStartIndex = 0 - cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) - } - } - } - } - - return m, tea.Batch(cmds...) -} - -func (m *editorCmp) setEditorPrompt() { - if m.app.Permissions.SkipRequests() { - m.textarea.SetPromptFunc(4, yoloPromptFunc) - return - } - m.textarea.SetPromptFunc(4, normalPromptFunc) -} - -func (m *editorCmp) completionsPosition() (int, int) { - cur := m.textarea.Cursor() - if cur == nil { - return m.x, m.y + 1 // adjust for padding - } - x := cur.X + m.x - y := cur.Y + m.y + 1 // adjust for padding - return x, y -} - -func (m *editorCmp) Cursor() *tea.Cursor { - cursor := m.textarea.Cursor() - if cursor != nil { - cursor.X = cursor.X + m.x + 1 - cursor.Y = cursor.Y + m.y + 1 // adjust for padding - } - return cursor -} - -var readyPlaceholders = [...]string{ - "Ready!", - "Ready...", - "Ready?", - "Ready for instructions", -} - -var workingPlaceholders = [...]string{ - "Working!", - "Working...", - "Brrrrr...", - "Prrrrrrrr...", - "Processing...", - "Thinking...", -} - -func (m *editorCmp) randomizePlaceholders() { - m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] - m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] -} - -func (m *editorCmp) View() string { - t := styles.CurrentTheme() - // Update placeholder - if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() { - m.textarea.Placeholder = m.workingPlaceholder - } else { - m.textarea.Placeholder = m.readyPlaceholder - } - if m.app.Permissions.SkipRequests() { - m.textarea.Placeholder = "Yolo mode!" - } - if len(m.attachments) == 0 { - return t.S().Base.Padding(1).Render( - m.textarea.View(), - ) - } - return t.S().Base.Padding(0, 1, 1, 1).Render( - lipgloss.JoinVertical( - lipgloss.Top, - m.attachmentsContent(), - m.textarea.View(), - ), - ) -} - -func (m *editorCmp) SetSize(width, height int) tea.Cmd { - m.width = width - m.height = height - m.textarea.SetWidth(width - 2) // adjust for padding - m.textarea.SetHeight(height - 2) // adjust for padding - return nil -} - -func (m *editorCmp) GetSize() (int, int) { - return m.textarea.Width(), m.textarea.Height() -} - -func (m *editorCmp) attachmentsContent() string { - var styledAttachments []string - t := styles.CurrentTheme() - attachmentStyle := t.S().Base. - Padding(0, 1). - MarginRight(1). - Background(t.FgMuted). - Foreground(t.FgBase). - Render - iconStyle := t.S().Base. - Foreground(t.BgSubtle). - Background(t.Green). - Padding(0, 1). - Bold(true). - Render - rmStyle := t.S().Base. - Padding(0, 1). - Bold(true). - Background(t.Red). - Foreground(t.FgBase). - Render - for i, attachment := range m.attachments { - filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...") - icon := styles.ImageIcon - if attachment.IsText() { - icon = styles.TextIcon - } - if m.deleteMode { - styledAttachments = append( - styledAttachments, - rmStyle(fmt.Sprintf("%d", i)), - attachmentStyle(filename), - ) - continue - } - styledAttachments = append( - styledAttachments, - iconStyle(icon), - attachmentStyle(filename), - ) - } - return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...) -} - -func (m *editorCmp) SetPosition(x, y int) tea.Cmd { - m.x = x - m.y = y - return nil -} - -func (m *editorCmp) startCompletions() tea.Msg { - ls := m.app.Config().Options.TUI.Completions - depth, limit := ls.Limits() - files, _, _ := fsext.ListDirectory(".", nil, depth, limit) - slices.Sort(files) - completionItems := make([]completions.Completion, 0, len(files)) - for _, file := range files { - file = strings.TrimPrefix(file, "./") - completionItems = append(completionItems, completions.Completion{ - Title: file, - Value: FileCompletionItem{ - Path: file, - }, - }) - } - - x, y := m.completionsPosition() - return completions.OpenCompletionsMsg{ - Completions: completionItems, - X: x, - Y: y, - MaxResults: maxFileResults, - } -} - -// Blur implements Container. -func (c *editorCmp) Blur() tea.Cmd { - c.textarea.Blur() - return nil -} - -// Focus implements Container. -func (c *editorCmp) Focus() tea.Cmd { - return c.textarea.Focus() -} - -// IsFocused implements Container. -func (c *editorCmp) IsFocused() bool { - return c.textarea.Focused() -} - -// Bindings implements Container. -func (c *editorCmp) Bindings() []key.Binding { - return c.keyMap.KeyBindings() -} - -// TODO: most likely we do not need to have the session here -// 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 -} - -func (c *editorCmp) IsCompletionsOpen() bool { - return c.isCompletionsOpen -} - -func (c *editorCmp) HasAttachments() bool { - return len(c.attachments) > 0 -} - -func (c *editorCmp) IsEmpty() bool { - return strings.TrimSpace(c.textarea.Value()) == "" -} - -func normalPromptFunc(info textarea.PromptInfo) string { - t := styles.CurrentTheme() - if info.LineNumber == 0 { - if info.Focused { - return " > " - } - return "::: " - } - if info.Focused { - return t.S().Base.Foreground(t.GreenDark).Render("::: ") - } - return t.S().Muted.Render("::: ") -} - -func yoloPromptFunc(info textarea.PromptInfo) string { - t := styles.CurrentTheme() - if info.LineNumber == 0 { - if info.Focused { - return fmt.Sprintf("%s ", t.YoloIconFocused) - } else { - return fmt.Sprintf("%s ", t.YoloIconBlurred) - } - } - if info.Focused { - return fmt.Sprintf("%s ", t.YoloDotsFocused) - } - return fmt.Sprintf("%s ", t.YoloDotsBlurred) -} - -func New(app *app.App) Editor { - t := styles.CurrentTheme() - ta := textarea.New() - ta.SetStyles(t.S().TextArea) - ta.ShowLineNumbers = false - ta.CharLimit = -1 - ta.SetVirtualCursor(false) - ta.Focus() - e := &editorCmp{ - // TODO: remove the app instance from here - app: app, - textarea: ta, - keyMap: DefaultEditorKeyMap(), - } - e.setEditorPrompt() - - e.randomizePlaceholders() - e.textarea.Placeholder = e.readyPlaceholder - - return e -} - -var maxAttachmentSize = 5 * 1024 * 1024 // 5MB - -var pasteRE = regexp.MustCompile(`paste_(\d+).txt`) - -func (m *editorCmp) pasteIdx() int { - result := 0 - for _, at := range m.attachments { - found := pasteRE.FindStringSubmatch(at.FileName) - if len(found) == 0 { - continue - } - idx, err := strconv.Atoi(found[1]) - if err == nil { - result = max(result, idx) - } - } - return result + 1 -} - -func filepathToFile(name string) ([]byte, string, error) { - path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", ""))) - if err != nil { - return nil, "", err - } - content, err := os.ReadFile(path) - if err != nil { - return nil, "", err - } - return content, path, nil -} - -func mimeOf(content []byte) string { - mimeBufferSize := min(512, len(content)) - return http.DetectContentType(content[:mimeBufferSize]) -} diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go deleted file mode 100644 index c20df5cc1c071deab83754430543b9be2381127c..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/keys.go +++ /dev/null @@ -1,77 +0,0 @@ -package editor - -import ( - "charm.land/bubbles/v2/key" -) - -type EditorKeyMap struct { - AddFile key.Binding - SendMessage key.Binding - OpenEditor key.Binding - Newline key.Binding - PasteImage key.Binding -} - -func DefaultEditorKeyMap() EditorKeyMap { - return EditorKeyMap{ - AddFile: key.NewBinding( - key.WithKeys("/"), - key.WithHelp("/", "add file"), - ), - SendMessage: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "send"), - ), - OpenEditor: key.NewBinding( - key.WithKeys("ctrl+o"), - key.WithHelp("ctrl+o", "open editor"), - ), - Newline: key.NewBinding( - key.WithKeys("shift+enter", "ctrl+j"), - // "ctrl+j" is a common keybinding for newline in many editors. If - // the terminal supports "shift+enter", we substitute the help text - // to reflect that. - key.WithHelp("ctrl+j", "newline"), - ), - PasteImage: key.NewBinding( - key.WithKeys("ctrl+v"), - key.WithHelp("ctrl+v", "paste image from clipboard"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k EditorKeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.AddFile, - k.SendMessage, - k.OpenEditor, - k.Newline, - k.PasteImage, - AttachmentsKeyMaps.AttachmentDeleteMode, - AttachmentsKeyMaps.DeleteAllAttachments, - AttachmentsKeyMaps.Escape, - } -} - -type DeleteAttachmentKeyMaps struct { - AttachmentDeleteMode key.Binding - Escape key.Binding - DeleteAllAttachments key.Binding -} - -// TODO: update this to use the new keymap concepts -var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{ - AttachmentDeleteMode: key.NewBinding( - key.WithKeys("ctrl+r"), - key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), - ), - Escape: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel delete mode"), - ), - DeleteAllAttachments: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("ctrl+r+r", "delete all attachments"), - ), -} diff --git a/internal/tui/components/chat/header/header.go b/internal/tui/components/chat/header/header.go deleted file mode 100644 index c8848440b1193fda9a7b5df4b31e03edeaf744c4..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/header/header.go +++ /dev/null @@ -1,160 +0,0 @@ -package header - -import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/csync" - "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/crush/internal/lsp" - "github.com/charmbracelet/crush/internal/pubsub" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" -) - -type Header interface { - util.Model - SetSession(session session.Session) tea.Cmd - SetWidth(width int) tea.Cmd - SetDetailsOpen(open bool) - ShowingDetails() bool -} - -type header struct { - width int - session session.Session - lspClients *csync.Map[string, *lsp.Client] - detailsOpen bool -} - -func New(lspClients *csync.Map[string, *lsp.Client]) Header { - return &header{ - lspClients: lspClients, - width: 0, - } -} - -func (h *header) Init() tea.Cmd { - return nil -} - -func (h *header) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case pubsub.Event[session.Session]: - if msg.Type == pubsub.UpdatedEvent { - if h.session.ID == msg.Payload.ID { - h.session = msg.Payload - } - } - } - return h, nil -} - -func (h *header) View() string { - if h.session.ID == "" { - return "" - } - - const ( - gap = " " - diag = "╱" - minDiags = 3 - leftPadding = 1 - rightPadding = 1 - ) - - t := styles.CurrentTheme() - - var b strings.Builder - - b.WriteString(t.S().Base.Foreground(t.Secondary).Render("Charm™")) - b.WriteString(gap) - b.WriteString(styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary)) - b.WriteString(gap) - - availDetailWidth := h.width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minDiags - details := h.details(availDetailWidth) - - remainingWidth := h.width - - lipgloss.Width(b.String()) - - lipgloss.Width(details) - - leftPadding - - rightPadding - - if remainingWidth > 0 { - b.WriteString(t.S().Base.Foreground(t.Primary).Render( - strings.Repeat(diag, max(minDiags, remainingWidth)), - )) - b.WriteString(gap) - } - - b.WriteString(details) - - return t.S().Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String()) -} - -func (h *header) details(availWidth int) string { - s := styles.CurrentTheme().S() - - var parts []string - - errorCount := 0 - for l := range h.lspClients.Seq() { - errorCount += l.GetDiagnosticCounts().Error - } - - if errorCount > 0 { - parts = append(parts, s.Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount))) - } - - agentCfg := config.Get().Agents[config.AgentCoder] - model := config.Get().GetModelByType(agentCfg.Model) - percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100 - formattedPercentage := s.Muted.Render(fmt.Sprintf("%d%%", int(percentage))) - parts = append(parts, formattedPercentage) - - const keystroke = "ctrl+d" - if h.detailsOpen { - parts = append(parts, s.Muted.Render(keystroke)+s.Subtle.Render(" close")) - } else { - parts = append(parts, s.Muted.Render(keystroke)+s.Subtle.Render(" open ")) - } - - dot := s.Subtle.Render(" • ") - metadata := strings.Join(parts, dot) - metadata = dot + metadata - - // Truncate cwd if necessary, and insert it at the beginning. - const dirTrimLimit = 4 - cwd := fsext.DirTrim(fsext.PrettyPath(config.Get().WorkingDir()), dirTrimLimit) - cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…") - cwd = s.Muted.Render(cwd) - - return cwd + metadata -} - -func (h *header) SetDetailsOpen(open bool) { - h.detailsOpen = open -} - -// SetSession implements Header. -func (h *header) SetSession(session session.Session) tea.Cmd { - h.session = session - return nil -} - -// SetWidth implements Header. -func (h *header) SetWidth(width int) tea.Cmd { - h.width = width - return nil -} - -// ShowingDetails implements Header. -func (h *header) ShowingDetails() bool { - return h.detailsOpen -} diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go deleted file mode 100644 index 3c91f9f41485b439b8c25ca0692c7265ccafb14a..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/messages/messages.go +++ /dev/null @@ -1,461 +0,0 @@ -package messages - -import ( - "fmt" - "path/filepath" - "strings" - "time" - - "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/x/ansi" - "github.com/charmbracelet/x/exp/ordered" - "github.com/google/uuid" - - "github.com/atotto/clipboard" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/tui/components/anim" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -// CopyKey is the key binding for copying message content to the clipboard. -var CopyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy")) - -// ClearSelectionKey is the key binding for clearing the current selection in the chat interface. -var ClearSelectionKey = key.NewBinding(key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "clear selection")) - -// MessageCmp defines the interface for message components in the chat interface. -// It combines standard UI model interfaces with message-specific functionality. -type MessageCmp interface { - util.Model // Basic Bubble util.Model interface - layout.Sizeable // Width/height management - layout.Focusable // Focus state management - GetMessage() message.Message // Access to underlying message data - SetMessage(msg message.Message) // Update the message content - Spinning() bool // Animation state for loading messages - ID() string -} - -// messageCmp implements the MessageCmp interface for displaying chat messages. -// It handles rendering of user and assistant messages with proper styling, -// animations, and state management. -type messageCmp struct { - width int // Component width for text wrapping - focused bool // Focus state for border styling - - // Core message data and state - message message.Message // The underlying message content - spinning bool // Whether to show loading animation - anim *anim.Anim // Animation component for loading states - - // Thinking viewport for displaying reasoning content - thinkingViewport viewport.Model -} - -var focusedMessageBorder = lipgloss.Border{ - Left: "▌", -} - -// NewMessageCmp creates a new message component with the given message and options -func NewMessageCmp(msg message.Message) MessageCmp { - t := styles.CurrentTheme() - - thinkingViewport := viewport.New() - thinkingViewport.SetHeight(1) - thinkingViewport.KeyMap = viewport.KeyMap{} - - m := &messageCmp{ - message: msg, - anim: anim.New(anim.Settings{ - Size: 15, - GradColorA: t.Primary, - GradColorB: t.Secondary, - CycleColors: true, - }), - thinkingViewport: thinkingViewport, - } - return m -} - -// Init initializes the message component and starts animations if needed. -// Returns a command to start the animation for spinning messages. -func (m *messageCmp) Init() tea.Cmd { - m.spinning = m.shouldSpin() - return m.anim.Init() -} - -// Update handles incoming messages and updates the component state. -// Manages animation updates for spinning messages and stops animation when appropriate. -func (m *messageCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case anim.StepMsg: - m.spinning = m.shouldSpin() - if m.spinning { - u, cmd := m.anim.Update(msg) - m.anim = u.(*anim.Anim) - return m, cmd - } - case tea.KeyPressMsg: - if key.Matches(msg, CopyKey) { - return m, tea.Sequence( - tea.SetClipboard(m.message.Content().Text), - func() tea.Msg { - _ = clipboard.WriteAll(m.message.Content().Text) - return nil - }, - util.ReportInfo("Message copied to clipboard"), - ) - } - } - return m, nil -} - -// View renders the message component based on its current state. -// Returns different views for spinning, user, and assistant messages. -func (m *messageCmp) View() string { - if m.spinning && m.message.ReasoningContent().Thinking == "" { - if m.message.IsSummaryMessage { - m.anim.SetLabel("Summarizing") - } - return m.style().PaddingLeft(1).Render(m.anim.View()) - } - if m.message.ID != "" { - // this is a user or assistant message - switch m.message.Role { - case message.User: - return m.renderUserMessage() - default: - return m.renderAssistantMessage() - } - } - return m.style().Render("No message content") -} - -// GetMessage returns the underlying message data -func (m *messageCmp) GetMessage() message.Message { - return m.message -} - -func (m *messageCmp) SetMessage(msg message.Message) { - m.message = msg -} - -// textWidth calculates the available width for text content, -// accounting for borders and padding -func (m *messageCmp) textWidth() int { - return m.width - 2 // take into account the border and/or padding -} - -// style returns the lipgloss style for the message component. -// Applies different border colors and styles based on message role and focus state. -func (msg *messageCmp) style() lipgloss.Style { - t := styles.CurrentTheme() - borderStyle := lipgloss.NormalBorder() - if msg.focused { - borderStyle = focusedMessageBorder - } - - style := t.S().Text - if msg.message.Role == message.User { - style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary) - } else { - if msg.focused { - style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark) - } else { - style = style.PaddingLeft(2) - } - } - return style -} - -// renderAssistantMessage renders assistant messages with optional footer information. -// Shows model name, response time, and finish reason when the message is complete. -func (m *messageCmp) renderAssistantMessage() string { - t := styles.CurrentTheme() - parts := []string{} - content := strings.TrimSpace(m.message.Content().String()) - thinking := m.message.IsThinking() - thinkingContent := strings.TrimSpace(m.message.ReasoningContent().Thinking) - finished := m.message.IsFinished() - finishedData := m.message.FinishPart() - - if thinking || thinkingContent != "" { - m.anim.SetLabel("Thinking") - thinkingContent = m.renderThinkingContent() - } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn { - // Don't render empty assistant messages with EndTurn - return "" - } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled { - content = "*Canceled*" - } else if finished && content == "" && finishedData.Reason == message.FinishReasonError { - errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR") - truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...") - title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated)) - details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details) - errorContent := fmt.Sprintf("%s\n\n%s", title, details) - return m.style().Render(errorContent) - } - - if thinkingContent != "" { - parts = append(parts, thinkingContent) - } - - if content != "" { - if thinkingContent != "" { - parts = append(parts, "") - } - parts = append(parts, m.toMarkdown(content)) - } - - joined := lipgloss.JoinVertical(lipgloss.Left, parts...) - return m.style().Render(joined) -} - -// renderUserMessage renders user messages with file attachments. It displays -// message content and any attached files with appropriate icons. -func (m *messageCmp) renderUserMessage() string { - t := styles.CurrentTheme() - var parts []string - - if s := m.message.Content().String(); s != "" { - parts = append(parts, m.toMarkdown(s)) - } - - attachmentStyle := t.S().Base. - Padding(0, 1). - MarginRight(1). - Background(t.FgMuted). - Foreground(t.FgBase). - Render - iconStyle := t.S().Base. - Foreground(t.BgSubtle). - Background(t.Green). - Padding(0, 1). - Bold(true). - Render - - attachments := make([]string, len(m.message.BinaryContent())) - for i, attachment := range m.message.BinaryContent() { - const maxFilenameWidth = 10 - filename := ansi.Truncate(filepath.Base(attachment.Path), 10, "...") - icon := styles.ImageIcon - if strings.HasPrefix(attachment.MIMEType, "text/") { - icon = styles.TextIcon - } - attachments[i] = lipgloss.JoinHorizontal( - lipgloss.Left, - iconStyle(icon), - attachmentStyle(filename), - ) - } - - if len(attachments) > 0 { - parts = append(parts, strings.Join(attachments, "")) - } - - joined := lipgloss.JoinVertical(lipgloss.Left, parts...) - return m.style().Render(joined) -} - -// toMarkdown converts text content to rendered markdown using the configured renderer -func (m *messageCmp) toMarkdown(content string) string { - r := styles.GetMarkdownRenderer(m.textWidth()) - rendered, _ := r.Render(content) - return strings.TrimSuffix(rendered, "\n") -} - -func (m *messageCmp) renderThinkingContent() string { - t := styles.CurrentTheme() - reasoningContent := m.message.ReasoningContent() - if strings.TrimSpace(reasoningContent.Thinking) == "" { - return "" - } - - width := m.textWidth() - 2 - width = min(width, 120) - - renderer := styles.GetPlainMarkdownRenderer(width - 1) - rendered, err := renderer.Render(reasoningContent.Thinking) - if err != nil { - lines := strings.Split(reasoningContent.Thinking, "\n") - var content strings.Builder - lineStyle := t.S().Subtle.Background(t.BgBaseLighter) - for i, line := range lines { - if line == "" { - continue - } - content.WriteString(lineStyle.Width(width).Render(line)) - if i < len(lines)-1 { - content.WriteString("\n") - } - } - rendered = content.String() - } - - fullContent := strings.TrimSpace(rendered) - height := ordered.Clamp(lipgloss.Height(fullContent), 1, 10) - m.thinkingViewport.SetHeight(height) - m.thinkingViewport.SetWidth(m.textWidth()) - m.thinkingViewport.SetContent(fullContent) - m.thinkingViewport.GotoBottom() - finishReason := m.message.FinishPart() - var footer string - if reasoningContent.StartedAt > 0 { - duration := m.message.ThinkingDuration() - if reasoningContent.FinishedAt > 0 { - m.anim.SetLabel("") - opts := core.StatusOpts{ - Title: "Thought for", - Description: duration.String(), - } - if duration.String() != "0s" { - footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1)) - } - } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled { - footer = t.S().Base.PaddingLeft(1).Render(m.toMarkdown("*Canceled*")) - } else { - footer = m.anim.View() - } - } - lineStyle := t.S().Subtle.Background(t.BgBaseLighter) - result := lineStyle.Width(m.textWidth()).Padding(0, 1, 0, 0).Render(m.thinkingViewport.View()) - if footer != "" { - result += "\n\n" + footer - } - return result -} - -// shouldSpin determines whether the message should show a loading animation. -// Only assistant messages without content that aren't finished should spin. -func (m *messageCmp) shouldSpin() bool { - if m.message.Role != message.Assistant { - return false - } - - if m.message.IsFinished() { - return false - } - - if strings.TrimSpace(m.message.Content().Text) != "" { - return false - } - if len(m.message.ToolCalls()) > 0 { - return false - } - return true -} - -// Blur removes focus from the message component -func (m *messageCmp) Blur() tea.Cmd { - m.focused = false - return nil -} - -// Focus sets focus on the message component -func (m *messageCmp) Focus() tea.Cmd { - m.focused = true - return nil -} - -// IsFocused returns whether the message component is currently focused -func (m *messageCmp) IsFocused() bool { - return m.focused -} - -// Size management methods - -// GetSize returns the current dimensions of the message component -func (m *messageCmp) GetSize() (int, int) { - return m.width, 0 -} - -// SetSize updates the width of the message component for text wrapping -func (m *messageCmp) SetSize(width int, height int) tea.Cmd { - m.width = ordered.Clamp(width, 1, 120) - m.thinkingViewport.SetWidth(m.width - 4) - return nil -} - -// Spinning returns whether the message is currently showing a loading animation -func (m *messageCmp) Spinning() bool { - return m.spinning -} - -type AssistantSection interface { - list.Item - layout.Sizeable -} -type assistantSectionModel struct { - width int - id string - message message.Message - lastUserMessageTime time.Time -} - -// ID implements AssistantSection. -func (m *assistantSectionModel) ID() string { - return m.id -} - -func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection { - return &assistantSectionModel{ - width: 0, - id: uuid.NewString(), - message: message, - lastUserMessageTime: lastUserMessageTime, - } -} - -func (m *assistantSectionModel) Init() tea.Cmd { - return nil -} - -func (m *assistantSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) { - return m, nil -} - -func (m *assistantSectionModel) View() string { - t := styles.CurrentTheme() - finishData := m.message.FinishPart() - finishTime := time.Unix(finishData.Time, 0) - duration := finishTime.Sub(m.lastUserMessageTime) - infoMsg := t.S().Subtle.Render(duration.String()) - icon := t.S().Subtle.Render(styles.ModelIcon) - model := config.Get().GetModel(m.message.Provider, m.message.Model) - if model == nil { - // This means the model is not configured anymore - model = &catwalk.Model{ - Name: "Unknown Model", - } - } - modelFormatted := t.S().Muted.Render(model.Name) - assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg) - return t.S().Base.PaddingLeft(2).Render( - core.Section(assistant, m.width-2), - ) -} - -func (m *assistantSectionModel) GetSize() (int, int) { - return m.width, 1 -} - -func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd { - m.width = width - return nil -} - -func (m *assistantSectionModel) IsSectionHeader() bool { - return true -} - -func (m *messageCmp) ID() string { - return m.message.ID -} diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go deleted file mode 100644 index 5fbd8a653c0b0374029bf13b31721d8ad5150948..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/messages/renderer.go +++ /dev/null @@ -1,1403 +0,0 @@ -package messages - -import ( - "cmp" - "encoding/json" - "fmt" - "strings" - "time" - - "charm.land/lipgloss/v2" - "charm.land/lipgloss/v2/tree" - "github.com/charmbracelet/crush/internal/agent" - "github.com/charmbracelet/crush/internal/agent/tools" - "github.com/charmbracelet/crush/internal/ansiext" - "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/crush/internal/tui/components/chat/todos" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/highlight" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/x/ansi" -) - -// responseContextHeight limits the number of lines displayed in tool output -const responseContextHeight = 10 - -// renderer defines the interface for tool-specific rendering implementations -type renderer interface { - // Render returns the complete (already styled) tool‑call view, not - // including the outer border. - Render(v *toolCallCmp) string -} - -// rendererFactory creates new renderer instances -type rendererFactory func() renderer - -// renderRegistry manages the mapping of tool names to their renderers -type renderRegistry map[string]rendererFactory - -// register adds a new renderer factory to the registry -func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f } - -// lookup retrieves a renderer for the given tool name, falling back to generic renderer -func (rr renderRegistry) lookup(name string) renderer { - if f, ok := rr[name]; ok { - return f() - } - return genericRenderer{} // sensible fallback -} - -// registry holds all registered tool renderers -var registry = renderRegistry{} - -// baseRenderer provides common functionality for all tool renderers -type baseRenderer struct{} - -func (br baseRenderer) Render(v *toolCallCmp) string { - if v.result.Data != "" { - if strings.HasPrefix(v.result.MIMEType, "image/") { - return br.renderWithParams(v, v.call.Name, nil, func() string { - return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content) - }) - } - return br.renderWithParams(v, v.call.Name, nil, func() string { - return renderMediaContent(v, v.result.MIMEType, v.result.Content) - }) - } - - return br.renderWithParams(v, v.call.Name, nil, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// paramBuilder helps construct parameter lists for tool headers -type paramBuilder struct { - args []string -} - -// newParamBuilder creates a new parameter builder -func newParamBuilder() *paramBuilder { - return ¶mBuilder{args: make([]string, 0)} -} - -// addMain adds the main parameter (first argument) -func (pb *paramBuilder) addMain(value string) *paramBuilder { - if value != "" { - pb.args = append(pb.args, value) - } - return pb -} - -// addKeyValue adds a key-value pair parameter -func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder { - if value != "" { - pb.args = append(pb.args, key, value) - } - return pb -} - -// addFlag adds a boolean flag parameter -func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder { - if value { - pb.args = append(pb.args, key, "true") - } - return pb -} - -// build returns the final parameter list -func (pb *paramBuilder) build() []string { - return pb.args -} - -// renderWithParams provides a common rendering pattern for tools with parameters -func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string { - width := v.textWidth() - if v.isNested { - width -= 4 // Adjust for nested tool call indentation - } - header := br.makeHeader(v, toolName, width, args...) - if v.isNested { - return v.style().Render(header) - } - if res, done := earlyState(header, v); done { - return res - } - body := contentRenderer() - return joinHeaderBody(header, body) -} - -// unmarshalParams safely unmarshal JSON parameters -func (br baseRenderer) unmarshalParams(input string, target any) error { - return json.Unmarshal([]byte(input), target) -} - -// makeHeader builds the tool call header with status icon and parameters for a nested tool call. -func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string { - t := styles.CurrentTheme() - icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) - if v.result.ToolCallID != "" { - if v.result.IsError { - icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError) - } else { - icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess) - } - } else if v.cancelled { - icon = t.S().Muted.Render(styles.ToolPending) - } - tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool) - prefix := fmt.Sprintf("%s %s ", icon, tool) - return prefix + renderParamList(true, width-lipgloss.Width(prefix), params...) -} - -// makeHeader builds ": param (key=value)" and truncates as needed. -func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string { - if v.isNested { - return br.makeNestedHeader(v, tool, width, params...) - } - t := styles.CurrentTheme() - icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) - if v.result.ToolCallID != "" { - if v.result.IsError { - icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError) - } else { - icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess) - } - } else if v.cancelled { - icon = t.S().Muted.Render(styles.ToolPending) - } - tool = t.S().Base.Foreground(t.Blue).Render(tool) - prefix := fmt.Sprintf("%s %s ", icon, tool) - return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...) -} - -// renderError provides consistent error rendering -func (br baseRenderer) renderError(v *toolCallCmp, message string) string { - t := styles.CurrentTheme() - header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "") - errorTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR") - message = t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(message, v.textWidth()-3-lipgloss.Width(errorTag))) // -2 for padding and space - return joinHeaderBody(header, errorTag+" "+message) -} - -// Register tool renderers -func init() { - registry.register(tools.BashToolName, func() renderer { return bashRenderer{} }) - registry.register(tools.JobOutputToolName, func() renderer { return bashOutputRenderer{} }) - registry.register(tools.JobKillToolName, func() renderer { return bashKillRenderer{} }) - registry.register(tools.DownloadToolName, func() renderer { return downloadRenderer{} }) - registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} }) - registry.register(tools.EditToolName, func() renderer { return editRenderer{} }) - registry.register(tools.MultiEditToolName, func() renderer { return multiEditRenderer{} }) - registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} }) - registry.register(tools.FetchToolName, func() renderer { return simpleFetchRenderer{} }) - registry.register(tools.AgenticFetchToolName, func() renderer { return agenticFetchRenderer{} }) - registry.register(tools.WebFetchToolName, func() renderer { return webFetchRenderer{} }) - registry.register(tools.WebSearchToolName, func() renderer { return webSearchRenderer{} }) - registry.register(tools.GlobToolName, func() renderer { return globRenderer{} }) - registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} }) - registry.register(tools.LSToolName, func() renderer { return lsRenderer{} }) - registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} }) - registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} }) - registry.register(tools.TodosToolName, func() renderer { return todosRenderer{} }) - registry.register(agent.AgentToolName, func() renderer { return agentRenderer{} }) -} - -// ----------------------------------------------------------------------------- -// Generic renderer -// ----------------------------------------------------------------------------- - -// genericRenderer handles unknown tool types with basic parameter display -type genericRenderer struct { - baseRenderer -} - -func (gr genericRenderer) Render(v *toolCallCmp) string { - if v.result.Data != "" { - if strings.HasPrefix(v.result.MIMEType, "image/") { - return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string { - return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content) - }) - } - return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string { - return renderMediaContent(v, v.result.MIMEType, v.result.Content) - }) - } - - return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Bash renderer -// ----------------------------------------------------------------------------- - -// bashRenderer handles bash command execution display -type bashRenderer struct { - baseRenderer -} - -// Render displays the bash command with sanitized newlines and plain output -func (br bashRenderer) Render(v *toolCallCmp) string { - var params tools.BashParams - if err := br.unmarshalParams(v.call.Input, ¶ms); err != nil { - return br.renderError(v, "Invalid bash parameters") - } - - cmd := strings.ReplaceAll(params.Command, "\n", " ") - cmd = strings.ReplaceAll(cmd, "\t", " ") - args := newParamBuilder(). - addMain(cmd). - addFlag("background", params.RunInBackground). - build() - if v.call.Finished { - var meta tools.BashResponseMetadata - _ = br.unmarshalParams(v.result.Metadata, &meta) - if meta.Background { - description := cmp.Or(meta.Description, params.Command) - width := v.textWidth() - if v.isNested { - width -= 4 // Adjust for nested tool call indentation - } - header := makeJobHeader(v, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width) - if v.isNested { - return v.style().Render(header) - } - if res, done := earlyState(header, v); done { - return res - } - content := "Command: " + params.Command + "\n" + v.result.Content - body := renderPlainContent(v, content) - return joinHeaderBody(header, body) - } - } - - return br.renderWithParams(v, "Bash", args, func() string { - var meta tools.BashResponseMetadata - if err := br.unmarshalParams(v.result.Metadata, &meta); err != nil { - return renderPlainContent(v, v.result.Content) - } - // for backwards compatibility with older tool calls. - if meta.Output == "" && v.result.Content != tools.BashNoOutput { - meta.Output = v.result.Content - } - - if meta.Output == "" { - return "" - } - return renderPlainContent(v, meta.Output) - }) -} - -// ----------------------------------------------------------------------------- -// Bash Output renderer -// ----------------------------------------------------------------------------- - -func makeJobHeader(v *toolCallCmp, subcommand, pid, description string, width int) string { - t := styles.CurrentTheme() - icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) - if v.result.ToolCallID != "" { - if v.result.IsError { - icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError) - } else { - icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess) - } - } else if v.cancelled { - icon = t.S().Muted.Render(styles.ToolPending) - } - - jobPart := t.S().Base.Foreground(t.Blue).Render("Job") - subcommandPart := t.S().Base.Foreground(t.BlueDark).Render("(" + subcommand + ")") - pidPart := t.S().Muted.Render(pid) - descPart := "" - if description != "" { - descPart = " " + t.S().Subtle.Render(description) - } - - // Build the complete header - prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, subcommandPart, pidPart) - fullHeader := prefix + descPart - - // Truncate if needed - if lipgloss.Width(fullHeader) > width { - availableWidth := width - lipgloss.Width(prefix) - 1 // -1 for space - if availableWidth < 10 { - // Not enough space for description, just show prefix - return prefix - } - descPart = " " + t.S().Subtle.Render(ansi.Truncate(description, availableWidth, "…")) - fullHeader = prefix + descPart - } - - return fullHeader -} - -// bashOutputRenderer handles bash output retrieval display -type bashOutputRenderer struct { - baseRenderer -} - -// Render displays the shell ID and output from a background shell -func (bor bashOutputRenderer) Render(v *toolCallCmp) string { - var params tools.JobOutputParams - if err := bor.unmarshalParams(v.call.Input, ¶ms); err != nil { - return bor.renderError(v, "Invalid job_output parameters") - } - - var meta tools.JobOutputResponseMetadata - var description string - if v.result.Metadata != "" { - if err := bor.unmarshalParams(v.result.Metadata, &meta); err == nil { - if meta.Description != "" { - description = meta.Description - } else { - description = meta.Command - } - } - } - - width := v.textWidth() - if v.isNested { - width -= 4 // Adjust for nested tool call indentation - } - header := makeJobHeader(v, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width) - if v.isNested { - return v.style().Render(header) - } - if res, done := earlyState(header, v); done { - return res - } - body := renderPlainContent(v, v.result.Content) - return joinHeaderBody(header, body) -} - -// ----------------------------------------------------------------------------- -// Bash Kill renderer -// ----------------------------------------------------------------------------- - -// bashKillRenderer handles bash process termination display -type bashKillRenderer struct { - baseRenderer -} - -// Render displays the shell ID being terminated -func (bkr bashKillRenderer) Render(v *toolCallCmp) string { - var params tools.JobKillParams - if err := bkr.unmarshalParams(v.call.Input, ¶ms); err != nil { - return bkr.renderError(v, "Invalid job_kill parameters") - } - - var meta tools.JobKillResponseMetadata - var description string - if v.result.Metadata != "" { - if err := bkr.unmarshalParams(v.result.Metadata, &meta); err == nil { - if meta.Description != "" { - description = meta.Description - } else { - description = meta.Command - } - } - } - - width := v.textWidth() - if v.isNested { - width -= 4 // Adjust for nested tool call indentation - } - header := makeJobHeader(v, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width) - if v.isNested { - return v.style().Render(header) - } - if res, done := earlyState(header, v); done { - return res - } - body := renderPlainContent(v, v.result.Content) - return joinHeaderBody(header, body) -} - -// ----------------------------------------------------------------------------- -// View renderer -// ----------------------------------------------------------------------------- - -// viewRenderer handles file viewing with syntax highlighting and line numbers -type viewRenderer struct { - baseRenderer -} - -// Render displays file content with optional limit and offset parameters -func (vr viewRenderer) Render(v *toolCallCmp) string { - var params tools.ViewParams - if err := vr.unmarshalParams(v.call.Input, ¶ms); err != nil { - return vr.renderError(v, "Invalid view parameters") - } - - file := fsext.PrettyPath(params.FilePath) - args := newParamBuilder(). - addMain(file). - addKeyValue("limit", formatNonZero(params.Limit)). - addKeyValue("offset", formatNonZero(params.Offset)). - build() - - return vr.renderWithParams(v, "View", args, func() string { - if v.result.Data != "" && strings.HasPrefix(v.result.MIMEType, "image/") { - return renderImageContent(v, v.result.Data, v.result.MIMEType, "") - } - - var meta tools.ViewResponseMetadata - if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil { - return renderPlainContent(v, v.result.Content) - } - return renderCodeContent(v, meta.FilePath, meta.Content, params.Offset) - }) -} - -// formatNonZero returns string representation of non-zero integers, empty string for zero -func formatNonZero(value int) string { - if value == 0 { - return "" - } - return fmt.Sprintf("%d", value) -} - -// ----------------------------------------------------------------------------- -// Edit renderer -// ----------------------------------------------------------------------------- - -// editRenderer handles file editing with diff visualization -type editRenderer struct { - baseRenderer -} - -// Render displays the edited file with a formatted diff of changes -func (er editRenderer) Render(v *toolCallCmp) string { - t := styles.CurrentTheme() - var params tools.EditParams - var args []string - if err := er.unmarshalParams(v.call.Input, ¶ms); err == nil { - file := fsext.PrettyPath(params.FilePath) - args = newParamBuilder().addMain(file).build() - } - - return er.renderWithParams(v, "Edit", args, func() string { - var meta tools.EditResponseMetadata - if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil { - return renderPlainContent(v, v.result.Content) - } - - formatter := core.DiffFormatter(). - Before(fsext.PrettyPath(params.FilePath), meta.OldContent). - After(fsext.PrettyPath(params.FilePath), meta.NewContent). - Width(v.textWidth() - 2) // -2 for padding - if v.textWidth() > 120 { - formatter = formatter.Split() - } - // add a message to the bottom if the content was truncated - formatted := formatter.String() - if lipgloss.Height(formatted) > responseContextHeight { - contentLines := strings.Split(formatted, "\n") - truncateMessage := t.S().Muted. - Background(t.BgBaseLighter). - PaddingLeft(2). - Width(v.textWidth() - 2). - Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight)) - formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage - } - return formatted - }) -} - -// ----------------------------------------------------------------------------- -// Multi-Edit renderer -// ----------------------------------------------------------------------------- - -// multiEditRenderer handles multiple file edits with diff visualization -type multiEditRenderer struct { - baseRenderer -} - -// Render displays the multi-edited file with a formatted diff of changes -func (mer multiEditRenderer) Render(v *toolCallCmp) string { - t := styles.CurrentTheme() - var params tools.MultiEditParams - var args []string - if err := mer.unmarshalParams(v.call.Input, ¶ms); err == nil { - file := fsext.PrettyPath(params.FilePath) - editsCount := len(params.Edits) - args = newParamBuilder(). - addMain(file). - addKeyValue("edits", fmt.Sprintf("%d", editsCount)). - build() - } - - return mer.renderWithParams(v, "Multi-Edit", args, func() string { - var meta tools.MultiEditResponseMetadata - if err := mer.unmarshalParams(v.result.Metadata, &meta); err != nil { - return renderPlainContent(v, v.result.Content) - } - - formatter := core.DiffFormatter(). - Before(fsext.PrettyPath(params.FilePath), meta.OldContent). - After(fsext.PrettyPath(params.FilePath), meta.NewContent). - Width(v.textWidth() - 2) // -2 for padding - if v.textWidth() > 120 { - formatter = formatter.Split() - } - // add a message to the bottom if the content was truncated - formatted := formatter.String() - if lipgloss.Height(formatted) > responseContextHeight { - contentLines := strings.Split(formatted, "\n") - truncateMessage := t.S().Muted. - Background(t.BgBaseLighter). - PaddingLeft(2). - Width(v.textWidth() - 4). - Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight)) - formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage - } - - // Add failed edits warning if any exist - if len(meta.EditsFailed) > 0 { - noteTag := t.S().Base.Padding(0, 2).Background(t.Info).Foreground(t.White).Render("Note") - noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, len(params.Edits)) - note := t.S().Base. - Width(v.textWidth() - 2). - Render(fmt.Sprintf("%s %s", noteTag, t.S().Muted.Render(noteMsg))) - formatted = lipgloss.JoinVertical(lipgloss.Left, formatted, "", note) - } - - return formatted - }) -} - -// ----------------------------------------------------------------------------- -// Write renderer -// ----------------------------------------------------------------------------- - -// writeRenderer handles file writing with syntax-highlighted content preview -type writeRenderer struct { - baseRenderer -} - -// Render displays the file being written with syntax highlighting -func (wr writeRenderer) Render(v *toolCallCmp) string { - var params tools.WriteParams - var args []string - var file string - if err := wr.unmarshalParams(v.call.Input, ¶ms); err == nil { - file = fsext.PrettyPath(params.FilePath) - args = newParamBuilder().addMain(file).build() - } - - return wr.renderWithParams(v, "Write", args, func() string { - return renderCodeContent(v, file, params.Content, 0) - }) -} - -// ----------------------------------------------------------------------------- -// Fetch renderer -// ----------------------------------------------------------------------------- - -// simpleFetchRenderer handles URL fetching with format-specific content display -type simpleFetchRenderer struct { - baseRenderer -} - -// Render displays the fetched URL with format and timeout parameters -func (fr simpleFetchRenderer) Render(v *toolCallCmp) string { - var params tools.FetchParams - var args []string - if err := fr.unmarshalParams(v.call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.URL). - addKeyValue("format", params.Format). - addKeyValue("timeout", formatTimeout(params.Timeout)). - build() - } - - return fr.renderWithParams(v, "Fetch", args, func() string { - file := fr.getFileExtension(params.Format) - return renderCodeContent(v, file, v.result.Content, 0) - }) -} - -// getFileExtension returns appropriate file extension for syntax highlighting -func (fr simpleFetchRenderer) getFileExtension(format string) string { - switch format { - case "text": - return "fetch.txt" - case "html": - return "fetch.html" - default: - return "fetch.md" - } -} - -// ----------------------------------------------------------------------------- -// Agentic fetch renderer -// ----------------------------------------------------------------------------- - -// agenticFetchRenderer handles URL fetching with prompt parameter and nested tool calls -type agenticFetchRenderer struct { - baseRenderer -} - -// Render displays the fetched URL or web search with prompt parameter and nested tool calls -func (fr agenticFetchRenderer) Render(v *toolCallCmp) string { - t := styles.CurrentTheme() - var params tools.AgenticFetchParams - var args []string - if err := fr.unmarshalParams(v.call.Input, ¶ms); err == nil { - if params.URL != "" { - args = newParamBuilder(). - addMain(params.URL). - build() - } - } - - prompt := params.Prompt - prompt = strings.ReplaceAll(prompt, "\n", " ") - - header := fr.makeHeader(v, "Agentic Fetch", v.textWidth(), args...) - if res, done := earlyState(header, v); v.cancelled && done { - return res - } - - taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.GreenLight).Foreground(t.Border).Render("Prompt") - remainingWidth := v.textWidth() - (lipgloss.Width(taskTag) + 1) - remainingWidth = min(remainingWidth, 120-(lipgloss.Width(taskTag)+1)) - prompt = t.S().Base.Width(remainingWidth).Render(prompt) - header = lipgloss.JoinVertical( - lipgloss.Left, - header, - "", - lipgloss.JoinHorizontal( - lipgloss.Left, - taskTag, - " ", - prompt, - ), - ) - childTools := tree.Root(header) - - for _, call := range v.nestedToolCalls { - call.SetSize(remainingWidth, 1) - childTools.Child(call.View()) - } - parts := []string{ - childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(), - } - - if v.result.ToolCallID == "" { - v.spinning = true - parts = append(parts, "", v.anim.View()) - } else { - v.spinning = false - } - - header = lipgloss.JoinVertical( - lipgloss.Left, - parts..., - ) - - if v.result.ToolCallID == "" { - return header - } - body := renderMarkdownContent(v, v.result.Content) - return joinHeaderBody(header, body) -} - -// formatTimeout converts timeout seconds to duration string -func formatTimeout(timeout int) string { - if timeout == 0 { - return "" - } - return (time.Duration(timeout) * time.Second).String() -} - -// ----------------------------------------------------------------------------- -// Web fetch renderer -// ----------------------------------------------------------------------------- - -// webFetchRenderer handles web page fetching with simplified URL display -type webFetchRenderer struct { - baseRenderer -} - -// Render displays a compact view of web_fetch with just the URL in a link style -func (wfr webFetchRenderer) Render(v *toolCallCmp) string { - var params tools.WebFetchParams - var args []string - if err := wfr.unmarshalParams(v.call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.URL). - build() - } - - return wfr.renderWithParams(v, "Fetch", args, func() string { - return renderMarkdownContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Web search renderer -// ----------------------------------------------------------------------------- - -// webSearchRenderer handles web search with query display -type webSearchRenderer struct { - baseRenderer -} - -// Render displays a compact view of web_search with just the query -func (wsr webSearchRenderer) Render(v *toolCallCmp) string { - var params tools.WebSearchParams - var args []string - if err := wsr.unmarshalParams(v.call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.Query). - build() - } - - return wsr.renderWithParams(v, "Search", args, func() string { - return renderMarkdownContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Download renderer -// ----------------------------------------------------------------------------- - -// downloadRenderer handles file downloading with URL and file path display -type downloadRenderer struct { - baseRenderer -} - -// Render displays the download URL and destination file path with timeout parameter -func (dr downloadRenderer) Render(v *toolCallCmp) string { - var params tools.DownloadParams - var args []string - if err := dr.unmarshalParams(v.call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.URL). - addKeyValue("file_path", fsext.PrettyPath(params.FilePath)). - addKeyValue("timeout", formatTimeout(params.Timeout)). - build() - } - - return dr.renderWithParams(v, "Download", args, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Glob renderer -// ----------------------------------------------------------------------------- - -// globRenderer handles file pattern matching with path filtering -type globRenderer struct { - baseRenderer -} - -// Render displays the glob pattern with optional path parameter -func (gr globRenderer) Render(v *toolCallCmp) string { - var params tools.GlobParams - var args []string - if err := gr.unmarshalParams(v.call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.Pattern). - addKeyValue("path", params.Path). - build() - } - - return gr.renderWithParams(v, "Glob", args, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Grep renderer -// ----------------------------------------------------------------------------- - -// grepRenderer handles content searching with pattern matching options -type grepRenderer struct { - baseRenderer -} - -// Render displays the search pattern with path, include, and literal text options -func (gr grepRenderer) Render(v *toolCallCmp) string { - var params tools.GrepParams - var args []string - if err := gr.unmarshalParams(v.call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.Pattern). - addKeyValue("path", params.Path). - addKeyValue("include", params.Include). - addFlag("literal", params.LiteralText). - build() - } - - return gr.renderWithParams(v, "Grep", args, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// LS renderer -// ----------------------------------------------------------------------------- - -// lsRenderer handles directory listing with default path handling -type lsRenderer struct { - baseRenderer -} - -// Render displays the directory path, defaulting to current directory -func (lr lsRenderer) Render(v *toolCallCmp) string { - var params tools.LSParams - var args []string - if err := lr.unmarshalParams(v.call.Input, ¶ms); err == nil { - path := params.Path - if path == "" { - path = "." - } - path = fsext.PrettyPath(path) - - args = newParamBuilder().addMain(path).build() - } - - return lr.renderWithParams(v, "List", args, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Sourcegraph renderer -// ----------------------------------------------------------------------------- - -// sourcegraphRenderer handles code search with count and context options -type sourcegraphRenderer struct { - baseRenderer -} - -// Render displays the search query with optional count and context window parameters -func (sr sourcegraphRenderer) Render(v *toolCallCmp) string { - var params tools.SourcegraphParams - var args []string - if err := sr.unmarshalParams(v.call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.Query). - addKeyValue("count", formatNonZero(params.Count)). - addKeyValue("context", formatNonZero(params.ContextWindow)). - build() - } - - return sr.renderWithParams(v, "Sourcegraph", args, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Diagnostics renderer -// ----------------------------------------------------------------------------- - -// diagnosticsRenderer handles project-wide diagnostic information -type diagnosticsRenderer struct { - baseRenderer -} - -// Render displays project diagnostics with plain content formatting -func (dr diagnosticsRenderer) Render(v *toolCallCmp) string { - args := newParamBuilder().addMain("project").build() - - return dr.renderWithParams(v, "Diagnostics", args, func() string { - return renderPlainContent(v, v.result.Content) - }) -} - -// ----------------------------------------------------------------------------- -// Task renderer -// ----------------------------------------------------------------------------- - -// agentRenderer handles project-wide diagnostic information -type agentRenderer struct { - baseRenderer -} - -func RoundedEnumeratorWithWidth(lPadding, width int) tree.Enumerator { - if width == 0 { - width = 2 - } - if lPadding == 0 { - lPadding = 1 - } - return func(children tree.Children, index int) string { - line := strings.Repeat("─", width) - padding := strings.Repeat(" ", lPadding) - if children.Length()-1 == index { - return padding + "╰" + line - } - return padding + "├" + line - } -} - -// Render displays agent task parameters and result content -func (tr agentRenderer) Render(v *toolCallCmp) string { - t := styles.CurrentTheme() - var params agent.AgentParams - tr.unmarshalParams(v.call.Input, ¶ms) - - prompt := params.Prompt - prompt = strings.ReplaceAll(prompt, "\n", " ") - - header := tr.makeHeader(v, "Agent", v.textWidth()) - if res, done := earlyState(header, v); v.cancelled && done { - return res - } - taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.BlueLight).Foreground(t.White).Render("Task") - remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2 - remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2) - prompt = t.S().Muted.Width(remainingWidth).Render(prompt) - header = lipgloss.JoinVertical( - lipgloss.Left, - header, - "", - lipgloss.JoinHorizontal( - lipgloss.Left, - taskTag, - " ", - prompt, - ), - ) - childTools := tree.Root(header) - - for _, call := range v.nestedToolCalls { - call.SetSize(remainingWidth, 1) - childTools.Child(call.View()) - } - parts := []string{ - childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(), - } - - if v.result.ToolCallID == "" { - v.spinning = true - parts = append(parts, "", v.anim.View()) - } else { - v.spinning = false - } - - header = lipgloss.JoinVertical( - lipgloss.Left, - parts..., - ) - - if v.result.ToolCallID == "" { - return header - } - - body := renderMarkdownContent(v, v.result.Content) - return joinHeaderBody(header, body) -} - -// renderParamList renders params, params[0] (params[1]=params[2] ....) -func renderParamList(nested bool, paramsWidth int, params ...string) string { - t := styles.CurrentTheme() - if len(params) == 0 { - return "" - } - mainParam := params[0] - if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth { - mainParam = ansi.Truncate(mainParam, paramsWidth, "…") - } - - if len(params) == 1 { - return t.S().Subtle.Render(mainParam) - } - otherParams := params[1:] - // create pairs of key/value - // if odd number of params, the last one is a key without value - if len(otherParams)%2 != 0 { - otherParams = append(otherParams, "") - } - parts := make([]string, 0, len(otherParams)/2) - for i := 0; i < len(otherParams); i += 2 { - key := otherParams[i] - value := otherParams[i+1] - if value == "" { - continue - } - parts = append(parts, fmt.Sprintf("%s=%s", key, value)) - } - - partsRendered := strings.Join(parts, ", ") - remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()" - if remainingWidth < 30 { - // No space for the params, just show the main - return t.S().Subtle.Render(mainParam) - } - - if len(parts) > 0 { - mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", ")) - } - - return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "…")) -} - -// earlyState returns immediately‑rendered error/cancelled/ongoing states. -func earlyState(header string, v *toolCallCmp) (string, bool) { - t := styles.CurrentTheme() - message := "" - switch { - case v.result.IsError: - message = v.renderToolError() - case v.cancelled: - 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 permission...") - } else { - message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool response...") - } - default: - return "", false - } - - message = t.S().Base.PaddingLeft(2).Render(message) - return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true -} - -func joinHeaderBody(header, body string) string { - t := styles.CurrentTheme() - if body == "" { - return header - } - body = t.S().Base.PaddingLeft(2).Render(body) - return lipgloss.JoinVertical(lipgloss.Left, header, "", body) -} - -func renderPlainContent(v *toolCallCmp, content string) string { - t := styles.CurrentTheme() - content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings - content = strings.ReplaceAll(content, "\t", " ") // Replace tabs with spaces - content = strings.TrimSpace(content) - lines := strings.Split(content, "\n") - - width := v.textWidth() - 2 - var out []string - for i, ln := range lines { - if i >= responseContextHeight { - break - } - ln = ansiext.Escape(ln) - ln = " " + ln - if lipgloss.Width(ln) > width { - ln = v.fit(ln, width) - } - out = append(out, t.S().Muted. - Width(width). - Background(t.BgBaseLighter). - Render(ln)) - } - - if len(lines) > responseContextHeight { - out = append(out, t.S().Muted. - Background(t.BgBaseLighter). - Width(width). - Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight))) - } - - return strings.Join(out, "\n") -} - -func renderMarkdownContent(v *toolCallCmp, content string) string { - t := styles.CurrentTheme() - content = strings.ReplaceAll(content, "\r\n", "\n") - content = strings.ReplaceAll(content, "\t", " ") - content = strings.TrimSpace(content) - - width := v.textWidth() - 2 - width = min(width, 120) - - renderer := styles.GetPlainMarkdownRenderer(width) - rendered, err := renderer.Render(content) - if err != nil { - return renderPlainContent(v, content) - } - - lines := strings.Split(rendered, "\n") - - var out []string - for i, ln := range lines { - if i >= responseContextHeight { - break - } - out = append(out, ln) - } - - style := t.S().Muted.Background(t.BgBaseLighter) - if len(lines) > responseContextHeight { - out = append(out, style. - Width(width-2). - Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight))) - } - - return style.Render(strings.Join(out, "\n")) -} - -func getDigits(n int) int { - if n == 0 { - return 1 - } - if n < 0 { - n = -n - } - - digits := 0 - for n > 0 { - n /= 10 - digits++ - } - - return digits -} - -func renderCodeContent(v *toolCallCmp, path, content string, offset int) string { - t := styles.CurrentTheme() - content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings - content = strings.ReplaceAll(content, "\t", " ") // Replace tabs with spaces - truncated := truncateHeight(content, responseContextHeight) - - lines := strings.Split(truncated, "\n") - for i, ln := range lines { - lines[i] = ansiext.Escape(ln) - } - - bg := t.BgBase - highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, bg) - lines = strings.Split(highlighted, "\n") - - if len(strings.Split(content, "\n")) > responseContextHeight { - lines = append(lines, t.S().Muted. - Background(bg). - Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight))) - } - - maxLineNumber := len(lines) + offset - maxDigits := getDigits(maxLineNumber) - numFmt := fmt.Sprintf("%%%dd", maxDigits) - const numPR, numPL, codePR, codePL = 1, 1, 1, 2 - w := v.textWidth() - maxDigits - numPL - numPR - 2 // -2 for left padding - for i, ln := range lines { - num := t.S().Base. - Foreground(t.FgMuted). - Background(t.BgBase). - PaddingRight(1). - PaddingLeft(1). - Render(fmt.Sprintf(numFmt, i+1+offset)) - lines[i] = lipgloss.JoinHorizontal(lipgloss.Left, - num, - t.S().Base. - Width(w). - Background(bg). - PaddingRight(1). - PaddingLeft(2). - Render(v.fit(ln, w-codePL-codePR)), - ) - } - - return lipgloss.JoinVertical(lipgloss.Left, lines...) -} - -// renderImageContent renders image data with optional text content (for MCP tools). -func renderImageContent(v *toolCallCmp, data, mediaType, textContent string) string { - t := styles.CurrentTheme() - - dataSize := len(data) * 3 / 4 - sizeStr := formatSize(dataSize) - - loaded := t.S().Base.Foreground(t.Green).Render("Loaded") - arrow := t.S().Base.Foreground(t.GreenDark).Render("→") - typeStyled := t.S().Base.Render(mediaType) - sizeStyled := t.S().Subtle.Render(sizeStr) - - imageDisplay := fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled) - if strings.TrimSpace(textContent) != "" { - textDisplay := renderPlainContent(v, textContent) - return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", imageDisplay) - } - - return imageDisplay -} - -// renderMediaContent renders non-image media content. -func renderMediaContent(v *toolCallCmp, mediaType, textContent string) string { - t := styles.CurrentTheme() - - loaded := t.S().Base.Foreground(t.Green).Render("Loaded") - arrow := t.S().Base.Foreground(t.GreenDark).Render("→") - typeStyled := t.S().Base.Render(mediaType) - mediaDisplay := fmt.Sprintf("%s %s %s", loaded, arrow, typeStyled) - - if strings.TrimSpace(textContent) != "" { - textDisplay := renderPlainContent(v, textContent) - return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", mediaDisplay) - } - - return mediaDisplay -} - -// formatSize formats byte count as human-readable size. -func formatSize(bytes int) string { - if bytes < 1024 { - return fmt.Sprintf("%d B", bytes) - } - if bytes < 1024*1024 { - return fmt.Sprintf("%.1f KB", float64(bytes)/1024) - } - return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024)) -} - -func (v *toolCallCmp) renderToolError() string { - t := styles.CurrentTheme() - err := strings.ReplaceAll(v.result.Content, "\n", " ") - errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR") - err = fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(err, v.textWidth()-2-lipgloss.Width(errTag)))) - return err -} - -func truncateHeight(s string, h int) string { - lines := strings.Split(s, "\n") - if len(lines) > h { - return strings.Join(lines[:h], "\n") - } - return s -} - -func prettifyToolName(name string) string { - switch name { - case agent.AgentToolName: - return "Agent" - case tools.BashToolName: - return "Bash" - case tools.JobOutputToolName: - return "Job: Output" - case tools.JobKillToolName: - return "Job: Kill" - case tools.DownloadToolName: - return "Download" - case tools.EditToolName: - return "Edit" - case tools.MultiEditToolName: - return "Multi-Edit" - case tools.FetchToolName: - return "Fetch" - case tools.AgenticFetchToolName: - return "Agentic Fetch" - case tools.WebFetchToolName: - return "Fetch" - case tools.WebSearchToolName: - return "Search" - case tools.GlobToolName: - return "Glob" - case tools.GrepToolName: - return "Grep" - case tools.LSToolName: - return "List" - case tools.SourcegraphToolName: - return "Sourcegraph" - case tools.TodosToolName: - return "To-Do" - case tools.ViewToolName: - return "View" - case tools.WriteToolName: - return "Write" - default: - return name - } -} - -// ----------------------------------------------------------------------------- -// Todos renderer -// ----------------------------------------------------------------------------- - -type todosRenderer struct { - baseRenderer -} - -func (tr todosRenderer) Render(v *toolCallCmp) string { - t := styles.CurrentTheme() - var params tools.TodosParams - var meta tools.TodosResponseMetadata - var headerText string - var body string - - // Parse params for pending state (before result is available). - if err := tr.unmarshalParams(v.call.Input, ¶ms); err == nil { - completedCount := 0 - inProgressTask := "" - for _, todo := range params.Todos { - if todo.Status == "completed" { - completedCount++ - } - if todo.Status == "in_progress" { - if todo.ActiveForm != "" { - inProgressTask = todo.ActiveForm - } else { - inProgressTask = todo.Content - } - } - } - - // Default display from params (used when pending or no metadata). - ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos))) - headerText = ratio - if inProgressTask != "" { - headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask) - } - - // If we have metadata, use it for richer display. - if v.result.Metadata != "" { - if err := tr.unmarshalParams(v.result.Metadata, &meta); err == nil { - if meta.IsNew { - if meta.JustStarted != "" { - headerText = fmt.Sprintf("created %d todos, starting first", meta.Total) - } else { - headerText = fmt.Sprintf("created %d todos", meta.Total) - } - body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth()) - } else { - // Build header based on what changed. - hasCompleted := len(meta.JustCompleted) > 0 - hasStarted := meta.JustStarted != "" - allCompleted := meta.Completed == meta.Total - - ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total)) - if hasCompleted && hasStarted { - text := t.S().Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted))) - headerText = fmt.Sprintf("%s%s", ratio, text) - } else if hasCompleted { - text := t.S().Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted))) - if allCompleted { - text = t.S().Subtle.Render(" · completed all") - } - headerText = fmt.Sprintf("%s%s", ratio, text) - } else if hasStarted { - headerText = fmt.Sprintf("%s%s", ratio, t.S().Subtle.Render(" · starting task")) - } else { - headerText = ratio - } - - // Build body with details. - if allCompleted { - // Show all todos when all are completed, like when created - body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth()) - } else if meta.JustStarted != "" { - body = t.S().Base.Foreground(t.GreenDark).Render(styles.ArrowRightIcon+" ") + - t.S().Base.Foreground(t.FgBase).Render(meta.JustStarted) - } - } - } - } - } - - args := newParamBuilder().addMain(headerText).build() - - return tr.renderWithParams(v, "To-Do", args, func() string { - return body - }) -} diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go deleted file mode 100644 index b8163f5a4c2a51f13ebd7ba2650bb7c3f33dac44..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/messages/tool.go +++ /dev/null @@ -1,877 +0,0 @@ -package messages - -import ( - "encoding/json" - "fmt" - "path/filepath" - "strings" - "time" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/atotto/clipboard" - "github.com/charmbracelet/crush/internal/agent" - "github.com/charmbracelet/crush/internal/agent/tools" - "github.com/charmbracelet/crush/internal/diff" - "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/crush/internal/tui/components/anim" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" -) - -// ToolCallCmp defines the interface for tool call components in the chat interface. -// It manages the display of tool execution including pending states, results, and errors. -type ToolCallCmp interface { - util.Model // Basic Bubble util.Model interface - layout.Sizeable // Width/height management - layout.Focusable // Focus state management - GetToolCall() message.ToolCall // Access to tool call data - GetToolResult() message.ToolResult // Access to tool result data - SetToolResult(message.ToolResult) // Update tool result - SetToolCall(message.ToolCall) // Update tool call - SetCancelled() // Mark as cancelled - ParentMessageID() string // Get parent message ID - Spinning() bool // Animation state for pending tools - GetNestedToolCalls() []ToolCallCmp // Get nested tool calls - SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls - SetIsNested(bool) // Set whether this tool call is nested - ID() string - SetPermissionRequested() // Mark permission request - SetPermissionGranted() // Mark permission granted -} - -// toolCallCmp implements the ToolCallCmp interface for displaying tool calls. -// It handles rendering of tool execution states including pending, completed, and error states. -type toolCallCmp struct { - width int // Component width for text wrapping - focused bool // Focus state for border styling - isNested bool // Whether this tool call is nested within another - - // Tool call data and state - parentMessageID string // ID of the message that initiated this tool call - call message.ToolCall // The tool call being executed - result message.ToolResult // The result of the tool execution - cancelled bool // Whether the tool call was cancelled - permissionRequested bool - permissionGranted bool - - // Animation state for pending tool calls - spinning bool // Whether to show loading animation - anim util.Model // Animation component for pending states - - nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display -} - -// ToolCallOption provides functional options for configuring tool call components -type ToolCallOption func(*toolCallCmp) - -// WithToolCallCancelled marks the tool call as cancelled -func WithToolCallCancelled() ToolCallOption { - return func(m *toolCallCmp) { - m.cancelled = true - } -} - -// WithToolCallResult sets the initial tool result -func WithToolCallResult(result message.ToolResult) ToolCallOption { - return func(m *toolCallCmp) { - m.result = result - } -} - -func WithToolCallNested(isNested bool) ToolCallOption { - return func(m *toolCallCmp) { - m.isNested = isNested - } -} - -func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption { - return func(m *toolCallCmp) { - m.nestedToolCalls = calls - } -} - -func WithToolPermissionRequested() ToolCallOption { - return func(m *toolCallCmp) { - m.permissionRequested = true - } -} - -func WithToolPermissionGranted() ToolCallOption { - return func(m *toolCallCmp) { - m.permissionGranted = true - } -} - -// NewToolCallCmp creates a new tool call component with the given parent message ID, -// tool call, and optional configuration -func NewToolCallCmp(parentMessageID string, tc message.ToolCall, permissions permission.Service, opts ...ToolCallOption) ToolCallCmp { - m := &toolCallCmp{ - call: tc, - parentMessageID: parentMessageID, - } - for _, opt := range opts { - opt(m) - } - t := styles.CurrentTheme() - m.anim = anim.New(anim.Settings{ - Size: 15, - Label: "Working", - GradColorA: t.Primary, - GradColorB: t.Secondary, - LabelColor: t.FgBase, - CycleColors: true, - }) - if m.isNested { - m.anim = anim.New(anim.Settings{ - Size: 10, - GradColorA: t.Primary, - GradColorB: t.Secondary, - CycleColors: true, - }) - } - return m -} - -// Init initializes the tool call component and starts animations if needed. -// Returns a command to start the animation for pending tool calls. -func (m *toolCallCmp) Init() tea.Cmd { - m.spinning = m.shouldSpin() - return m.anim.Init() -} - -// Update handles incoming messages and updates the component state. -// Manages animation updates for pending tool calls. -func (m *toolCallCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case anim.StepMsg: - var cmds []tea.Cmd - for i, nested := range m.nestedToolCalls { - if nested.Spinning() { - u, cmd := nested.Update(msg) - m.nestedToolCalls[i] = u.(ToolCallCmp) - cmds = append(cmds, cmd) - } - } - if m.spinning { - u, cmd := m.anim.Update(msg) - m.anim = u - cmds = append(cmds, cmd) - } - return m, tea.Batch(cmds...) - case tea.KeyPressMsg: - if key.Matches(msg, CopyKey) { - return m, m.copyTool() - } - } - return m, nil -} - -// View renders the tool call component based on its current state. -// Shows either a pending animation or the tool-specific rendered result. -func (m *toolCallCmp) View() string { - box := m.style() - - if !m.call.Finished && !m.cancelled { - return box.Render(m.renderPending()) - } - - r := registry.lookup(m.call.Name) - - if m.isNested { - return box.Render(r.Render(m)) - } - return box.Render(r.Render(m)) -} - -// State management methods - -// SetCancelled marks the tool call as cancelled -func (m *toolCallCmp) SetCancelled() { - m.cancelled = true -} - -func (m *toolCallCmp) copyTool() tea.Cmd { - content := m.formatToolForCopy() - return tea.Sequence( - tea.SetClipboard(content), - func() tea.Msg { - _ = clipboard.WriteAll(content) - return nil - }, - util.ReportInfo("Tool content copied to clipboard"), - ) -} - -func (m *toolCallCmp) formatToolForCopy() string { - var parts []string - - toolName := prettifyToolName(m.call.Name) - parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName)) - - if m.call.Input != "" { - params := m.formatParametersForCopy() - if params != "" { - parts = append(parts, "### Parameters:") - parts = append(parts, params) - } - } - - if m.result.ToolCallID != "" { - if m.result.IsError { - parts = append(parts, "### Error:") - parts = append(parts, m.result.Content) - } else { - parts = append(parts, "### Result:") - content := m.formatResultForCopy() - if content != "" { - parts = append(parts, content) - } - } - } else if m.cancelled { - parts = append(parts, "### Status:") - parts = append(parts, "Cancelled") - } else { - parts = append(parts, "### Status:") - parts = append(parts, "Pending...") - } - - return strings.Join(parts, "\n\n") -} - -func (m *toolCallCmp) formatParametersForCopy() string { - switch m.call.Name { - case tools.BashToolName: - var params tools.BashParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - cmd := strings.ReplaceAll(params.Command, "\n", " ") - cmd = strings.ReplaceAll(cmd, "\t", " ") - return fmt.Sprintf("**Command:** %s", cmd) - } - case tools.ViewToolName: - var params tools.ViewParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))) - if params.Limit > 0 { - parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit)) - } - if params.Offset > 0 { - parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset)) - } - return strings.Join(parts, "\n") - } - case tools.EditToolName: - var params tools.EditParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) - } - case tools.MultiEditToolName: - var params tools.MultiEditParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))) - parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits))) - return strings.Join(parts, "\n") - } - case tools.WriteToolName: - var params tools.WriteParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) - } - case tools.FetchToolName: - var params tools.FetchParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL)) - if params.Format != "" { - parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format)) - } - if params.Timeout > 0 { - parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout)) - } - return strings.Join(parts, "\n") - } - case tools.AgenticFetchToolName: - var params tools.AgenticFetchParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - if params.URL != "" { - parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL)) - } - if params.Prompt != "" { - parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt)) - } - return strings.Join(parts, "\n") - } - case tools.WebFetchToolName: - var params tools.WebFetchParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - return fmt.Sprintf("**URL:** %s", params.URL) - } - case tools.GrepToolName: - var params tools.GrepParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern)) - if params.Path != "" { - parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path)) - } - if params.Include != "" { - parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include)) - } - if params.LiteralText { - parts = append(parts, "**Literal:** true") - } - return strings.Join(parts, "\n") - } - case tools.GlobToolName: - var params tools.GlobParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern)) - if params.Path != "" { - parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path)) - } - return strings.Join(parts, "\n") - } - case tools.LSToolName: - var params tools.LSParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - path := params.Path - if path == "" { - path = "." - } - return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path)) - } - case tools.DownloadToolName: - var params tools.DownloadParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL)) - parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath))) - if params.Timeout > 0 { - parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String())) - } - return strings.Join(parts, "\n") - } - case tools.SourcegraphToolName: - var params tools.SourcegraphParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query)) - if params.Count > 0 { - parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count)) - } - if params.ContextWindow > 0 { - parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow)) - } - return strings.Join(parts, "\n") - } - case tools.DiagnosticsToolName: - return "**Project:** diagnostics" - case agent.AgentToolName: - var params agent.AgentParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - return fmt.Sprintf("**Task:**\n%s", params.Prompt) - } - } - - var params map[string]any - if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { - var parts []string - for key, value := range params { - displayKey := strings.ReplaceAll(key, "_", " ") - if len(displayKey) > 0 { - displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:] - } - parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value)) - } - return strings.Join(parts, "\n") - } - - return "" -} - -func (m *toolCallCmp) formatResultForCopy() string { - if m.result.Data != "" { - if strings.HasPrefix(m.result.MIMEType, "image/") { - return fmt.Sprintf("[Image: %s]", m.result.MIMEType) - } - return fmt.Sprintf("[Media: %s]", m.result.MIMEType) - } - - switch m.call.Name { - case tools.BashToolName: - return m.formatBashResultForCopy() - case tools.ViewToolName: - return m.formatViewResultForCopy() - case tools.EditToolName: - return m.formatEditResultForCopy() - case tools.MultiEditToolName: - return m.formatMultiEditResultForCopy() - case tools.WriteToolName: - return m.formatWriteResultForCopy() - case tools.FetchToolName: - return m.formatFetchResultForCopy() - case tools.AgenticFetchToolName: - return m.formatAgenticFetchResultForCopy() - case tools.WebFetchToolName: - return m.formatWebFetchResultForCopy() - case agent.AgentToolName: - return m.formatAgentResultForCopy() - case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName: - return fmt.Sprintf("```\n%s\n```", m.result.Content) - default: - return m.result.Content - } -} - -func (m *toolCallCmp) formatBashResultForCopy() string { - var meta tools.BashResponseMetadata - if m.result.Metadata != "" { - json.Unmarshal([]byte(m.result.Metadata), &meta) - } - - output := meta.Output - if output == "" && m.result.Content != tools.BashNoOutput { - output = m.result.Content - } - - if output == "" { - return "" - } - - return fmt.Sprintf("```bash\n%s\n```", output) -} - -func (m *toolCallCmp) formatViewResultForCopy() string { - var meta tools.ViewResponseMetadata - if m.result.Metadata != "" { - json.Unmarshal([]byte(m.result.Metadata), &meta) - } - - if meta.Content == "" { - return m.result.Content - } - - lang := "" - if meta.FilePath != "" { - ext := strings.ToLower(filepath.Ext(meta.FilePath)) - switch ext { - case ".go": - lang = "go" - case ".js", ".mjs": - lang = "javascript" - case ".ts": - lang = "typescript" - case ".py": - lang = "python" - case ".rs": - lang = "rust" - case ".java": - lang = "java" - case ".c": - lang = "c" - case ".cpp", ".cc", ".cxx": - lang = "cpp" - case ".sh", ".bash": - lang = "bash" - case ".json": - lang = "json" - case ".yaml", ".yml": - lang = "yaml" - case ".xml": - lang = "xml" - case ".html": - lang = "html" - case ".css": - lang = "css" - case ".md": - lang = "markdown" - } - } - - var result strings.Builder - if lang != "" { - result.WriteString(fmt.Sprintf("```%s\n", lang)) - } else { - result.WriteString("```\n") - } - result.WriteString(meta.Content) - result.WriteString("\n```") - - return result.String() -} - -func (m *toolCallCmp) formatEditResultForCopy() string { - var meta tools.EditResponseMetadata - if m.result.Metadata == "" { - return m.result.Content - } - - if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil { - return m.result.Content - } - - var params tools.EditParams - json.Unmarshal([]byte(m.call.Input), ¶ms) - - var result strings.Builder - - if meta.OldContent != "" || meta.NewContent != "" { - fileName := params.FilePath - if fileName != "" { - fileName = fsext.PrettyPath(fileName) - } - diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName) - - result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals)) - result.WriteString("```diff\n") - result.WriteString(diffContent) - result.WriteString("\n```") - } - - return result.String() -} - -func (m *toolCallCmp) formatMultiEditResultForCopy() string { - var meta tools.MultiEditResponseMetadata - if m.result.Metadata == "" { - return m.result.Content - } - - if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil { - return m.result.Content - } - - var params tools.MultiEditParams - json.Unmarshal([]byte(m.call.Input), ¶ms) - - var result strings.Builder - if meta.OldContent != "" || meta.NewContent != "" { - fileName := params.FilePath - if fileName != "" { - fileName = fsext.PrettyPath(fileName) - } - diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName) - - result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals)) - result.WriteString("```diff\n") - result.WriteString(diffContent) - result.WriteString("\n```") - } - - return result.String() -} - -func (m *toolCallCmp) formatWriteResultForCopy() string { - var params tools.WriteParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil { - return m.result.Content - } - - lang := "" - if params.FilePath != "" { - ext := strings.ToLower(filepath.Ext(params.FilePath)) - switch ext { - case ".go": - lang = "go" - case ".js", ".mjs": - lang = "javascript" - case ".ts": - lang = "typescript" - case ".py": - lang = "python" - case ".rs": - lang = "rust" - case ".java": - lang = "java" - case ".c": - lang = "c" - case ".cpp", ".cc", ".cxx": - lang = "cpp" - case ".sh", ".bash": - lang = "bash" - case ".json": - lang = "json" - case ".yaml", ".yml": - lang = "yaml" - case ".xml": - lang = "xml" - case ".html": - lang = "html" - case ".css": - lang = "css" - case ".md": - lang = "markdown" - } - } - - var result strings.Builder - result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath))) - if lang != "" { - result.WriteString(fmt.Sprintf("```%s\n", lang)) - } else { - result.WriteString("```\n") - } - result.WriteString(params.Content) - result.WriteString("\n```") - - return result.String() -} - -func (m *toolCallCmp) formatFetchResultForCopy() string { - var params tools.FetchParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil { - return m.result.Content - } - - var result strings.Builder - if params.URL != "" { - result.WriteString(fmt.Sprintf("URL: %s\n", params.URL)) - } - if params.Format != "" { - result.WriteString(fmt.Sprintf("Format: %s\n", params.Format)) - } - if params.Timeout > 0 { - result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout)) - } - result.WriteString("\n") - - result.WriteString(m.result.Content) - - return result.String() -} - -func (m *toolCallCmp) formatAgenticFetchResultForCopy() string { - var params tools.AgenticFetchParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil { - return m.result.Content - } - - var result strings.Builder - if params.URL != "" { - result.WriteString(fmt.Sprintf("URL: %s\n", params.URL)) - } - if params.Prompt != "" { - result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt)) - } - - result.WriteString("```markdown\n") - result.WriteString(m.result.Content) - result.WriteString("\n```") - - return result.String() -} - -func (m *toolCallCmp) formatWebFetchResultForCopy() string { - var params tools.WebFetchParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil { - return m.result.Content - } - - var result strings.Builder - result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL)) - result.WriteString("```markdown\n") - result.WriteString(m.result.Content) - result.WriteString("\n```") - - return result.String() -} - -func (m *toolCallCmp) formatAgentResultForCopy() string { - var result strings.Builder - - if len(m.nestedToolCalls) > 0 { - result.WriteString("### Nested Tool Calls:\n") - for i, nestedCall := range m.nestedToolCalls { - nestedContent := nestedCall.(*toolCallCmp).formatToolForCopy() - indentedContent := strings.ReplaceAll(nestedContent, "\n", "\n ") - result.WriteString(fmt.Sprintf("%d. %s\n", i+1, indentedContent)) - if i < len(m.nestedToolCalls)-1 { - result.WriteString("\n") - } - } - - if m.result.Content != "" { - result.WriteString("\n### Final Result:\n") - } - } - - if m.result.Content != "" { - result.WriteString(fmt.Sprintf("```markdown\n%s\n```", m.result.Content)) - } - - return result.String() -} - -// SetToolCall updates the tool call data and stops spinning if finished -func (m *toolCallCmp) SetToolCall(call message.ToolCall) { - m.call = call - if m.call.Finished { - m.spinning = false - } -} - -// ParentMessageID returns the ID of the message that initiated this tool call -func (m *toolCallCmp) ParentMessageID() string { - return m.parentMessageID -} - -// SetToolResult updates the tool result and stops the spinning animation -func (m *toolCallCmp) SetToolResult(result message.ToolResult) { - m.result = result - m.spinning = false -} - -// GetToolCall returns the current tool call data -func (m *toolCallCmp) GetToolCall() message.ToolCall { - return m.call -} - -// GetToolResult returns the current tool result data -func (m *toolCallCmp) GetToolResult() message.ToolResult { - return m.result -} - -// GetNestedToolCalls returns the nested tool calls -func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp { - return m.nestedToolCalls -} - -// SetNestedToolCalls sets the nested tool calls -func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) { - m.nestedToolCalls = calls - for _, nested := range m.nestedToolCalls { - nested.SetSize(m.width, 0) - } -} - -// SetIsNested sets whether this tool call is nested within another -func (m *toolCallCmp) SetIsNested(isNested bool) { - m.isNested = isNested -} - -// Rendering methods - -// renderPending displays the tool name with a loading animation for pending tool calls -func (m *toolCallCmp) renderPending() string { - t := styles.CurrentTheme() - icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) - if m.isNested { - tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name)) - return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View()) - } - tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name)) - return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View()) -} - -// style returns the lipgloss style for the tool call component. -// Applies muted colors and focus-dependent border styles. -func (m *toolCallCmp) style() lipgloss.Style { - t := styles.CurrentTheme() - - if m.isNested { - return t.S().Muted - } - style := t.S().Muted.PaddingLeft(2) - - if m.focused { - style = style.PaddingLeft(1).BorderStyle(focusedMessageBorder).BorderLeft(true).BorderForeground(t.GreenDark) - } - return style -} - -// textWidth calculates the available width for text content, -// accounting for borders and padding -func (m *toolCallCmp) textWidth() int { - if m.isNested { - return m.width - 6 - } - return m.width - 5 // take into account the border and PaddingLeft -} - -// fit truncates content to fit within the specified width with ellipsis -func (m *toolCallCmp) fit(content string, width int) string { - if lipgloss.Width(content) <= width { - return content - } - t := styles.CurrentTheme() - lineStyle := t.S().Muted - dots := lineStyle.Render("…") - return ansi.Truncate(content, width, dots) -} - -// Focus management methods - -// Blur removes focus from the tool call component -func (m *toolCallCmp) Blur() tea.Cmd { - m.focused = false - return nil -} - -// Focus sets focus on the tool call component -func (m *toolCallCmp) Focus() tea.Cmd { - m.focused = true - return nil -} - -// IsFocused returns whether the tool call component is currently focused -func (m *toolCallCmp) IsFocused() bool { - return m.focused -} - -// Size management methods - -// GetSize returns the current dimensions of the tool call component -func (m *toolCallCmp) GetSize() (int, int) { - return m.width, 0 -} - -// SetSize updates the width of the tool call component for text wrapping -func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd { - m.width = width - for _, nested := range m.nestedToolCalls { - nested.SetSize(width, height) - } - return nil -} - -// shouldSpin determines whether the tool call should show a loading animation. -// Returns true if the tool call is not finished or if the result doesn't match the call ID. -func (m *toolCallCmp) shouldSpin() bool { - return !m.call.Finished && !m.cancelled -} - -// Spinning returns whether the tool call is currently showing a loading animation -func (m *toolCallCmp) Spinning() bool { - if m.spinning { - return true - } - for _, nested := range m.nestedToolCalls { - if nested.Spinning() { - return true - } - } - return m.spinning -} - -func (m *toolCallCmp) ID() string { - return m.call.ID -} - -// SetPermissionRequested marks that a permission request was made for this tool call -func (m *toolCallCmp) SetPermissionRequested() { - m.permissionRequested = true -} - -// SetPermissionGranted marks that permission was granted for this tool call -func (m *toolCallCmp) SetPermissionGranted() { - m.permissionGranted = true -} diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go deleted file mode 100644 index 40bc8821e0a3dc7c3dec62bbcde34a5241ec4aa7..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ /dev/null @@ -1,608 +0,0 @@ -package sidebar - -import ( - "context" - "fmt" - "slices" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/csync" - "github.com/charmbracelet/crush/internal/diff" - "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/crush/internal/history" - "github.com/charmbracelet/crush/internal/home" - "github.com/charmbracelet/crush/internal/lsp" - "github.com/charmbracelet/crush/internal/pubsub" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/files" - "github.com/charmbracelet/crush/internal/tui/components/logo" - lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp" - "github.com/charmbracelet/crush/internal/tui/components/mcp" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/crush/internal/version" - "golang.org/x/text/cases" - "golang.org/x/text/language" -) - -type FileHistory struct { - initialVersion history.File - latestVersion history.File -} - -const LogoHeightBreakpoint = 30 - -// Default maximum number of items to show in each section -const ( - DefaultMaxFilesShown = 10 - DefaultMaxLSPsShown = 8 - DefaultMaxMCPsShown = 8 - MinItemsPerSection = 2 // Minimum items to show per section -) - -type SessionFile struct { - History FileHistory - FilePath string - Additions int - Deletions int -} -type SessionFilesMsg struct { - Files []SessionFile -} - -type Sidebar interface { - util.Model - layout.Sizeable - SetSession(session session.Session) tea.Cmd - SetCompactMode(bool) -} - -type sidebarCmp struct { - width, height int - session session.Session - logo string - cwd string - lspClients *csync.Map[string, *lsp.Client] - compactMode bool - history history.Service - files *csync.Map[string, SessionFile] -} - -func New(history history.Service, lspClients *csync.Map[string, *lsp.Client], compact bool) Sidebar { - return &sidebarCmp{ - lspClients: lspClients, - history: history, - compactMode: compact, - files: csync.NewMap[string, SessionFile](), - } -} - -func (m *sidebarCmp) Init() tea.Cmd { - return nil -} - -func (m *sidebarCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case SessionFilesMsg: - m.files = csync.NewMap[string, SessionFile]() - for _, file := range msg.Files { - m.files.Set(file.FilePath, file) - } - return m, nil - - case chat.SessionClearedMsg: - m.session = session.Session{} - case pubsub.Event[history.File]: - return m, m.handleFileHistoryEvent(msg) - case pubsub.Event[session.Session]: - if msg.Type == pubsub.UpdatedEvent { - if m.session.ID == msg.Payload.ID { - m.session = msg.Payload - } - } - } - return m, nil -} - -func (m *sidebarCmp) View() string { - t := styles.CurrentTheme() - parts := []string{} - - style := t.S().Base. - Width(m.width). - Height(m.height). - Padding(1) - if m.compactMode { - style = style.PaddingTop(0) - } - - if !m.compactMode { - if m.height > LogoHeightBreakpoint { - parts = append(parts, m.logo) - } else { - // Use a smaller logo for smaller screens - parts = append(parts, - logo.SmallRender(m.width-style.GetHorizontalFrameSize()), - "") - } - } - - if !m.compactMode && m.session.ID != "" { - parts = append(parts, t.S().Muted.Render(m.session.Title), "") - } else if m.session.ID != "" { - parts = append(parts, t.S().Text.Render(m.session.Title), "") - } - - if !m.compactMode { - parts = append(parts, - m.cwd, - "", - ) - } - parts = append(parts, - m.currentModelBlock(), - ) - - // Check if we should use horizontal layout for sections - if m.compactMode && m.width > m.height { - // Horizontal layout for compact mode when width > height - sectionsContent := m.renderSectionsHorizontal() - if sectionsContent != "" { - parts = append(parts, "", sectionsContent) - } - } else { - // Vertical layout (default) - if m.session.ID != "" { - parts = append(parts, "", m.filesBlock()) - } - parts = append(parts, - "", - m.lspBlock(), - "", - m.mcpBlock(), - ) - } - - return style.Render( - lipgloss.JoinVertical(lipgloss.Left, parts...), - ) -} - -func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd { - return func() tea.Msg { - file := event.Payload - found := false - for existing := range m.files.Seq() { - if existing.FilePath != file.Path { - continue - } - if existing.History.latestVersion.Version < file.Version { - existing.History.latestVersion = file - } else if file.Version == 0 { - existing.History.initialVersion = file - } else { - // If the version is not greater than the latest, we ignore it - continue - } - before, _ := fsext.ToUnixLineEndings(existing.History.initialVersion.Content) - after, _ := fsext.ToUnixLineEndings(existing.History.latestVersion.Content) - path := existing.History.initialVersion.Path - cwd := config.Get().WorkingDir() - path = strings.TrimPrefix(path, cwd) - _, additions, deletions := diff.GenerateDiff(before, after, path) - existing.Additions = additions - existing.Deletions = deletions - m.files.Set(file.Path, existing) - found = true - break - } - if found { - return nil - } - sf := SessionFile{ - History: FileHistory{ - initialVersion: file, - latestVersion: file, - }, - FilePath: file.Path, - Additions: 0, - Deletions: 0, - } - m.files.Set(file.Path, sf) - return nil - } -} - -func (m *sidebarCmp) loadSessionFiles() tea.Msg { - files, err := m.history.ListBySession(context.Background(), m.session.ID) - if err != nil { - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: err.Error(), - } - } - - fileMap := make(map[string]FileHistory) - - for _, file := range files { - if existing, ok := fileMap[file.Path]; ok { - // Update the latest version - existing.latestVersion = file - fileMap[file.Path] = existing - } else { - // Add the initial version - fileMap[file.Path] = FileHistory{ - initialVersion: file, - latestVersion: file, - } - } - } - - sessionFiles := make([]SessionFile, 0, len(fileMap)) - for path, fh := range fileMap { - cwd := config.Get().WorkingDir() - path = strings.TrimPrefix(path, cwd) - before, _ := fsext.ToUnixLineEndings(fh.initialVersion.Content) - after, _ := fsext.ToUnixLineEndings(fh.latestVersion.Content) - _, additions, deletions := diff.GenerateDiff(before, after, path) - sessionFiles = append(sessionFiles, SessionFile{ - History: fh, - FilePath: path, - Additions: additions, - Deletions: deletions, - }) - } - - return SessionFilesMsg{ - Files: sessionFiles, - } -} - -func (m *sidebarCmp) SetSize(width, height int) tea.Cmd { - m.logo = m.logoBlock() - m.cwd = cwd() - m.width = width - m.height = height - return nil -} - -func (m *sidebarCmp) GetSize() (int, int) { - return m.width, m.height -} - -func (m *sidebarCmp) logoBlock() string { - t := styles.CurrentTheme() - return logo.Render(version.Version, true, logo.Opts{ - FieldColor: t.Primary, - TitleColorA: t.Secondary, - TitleColorB: t.Primary, - CharmColor: t.Secondary, - VersionColor: t.Primary, - Width: m.width - 2, - }) -} - -func (m *sidebarCmp) getMaxWidth() int { - return min(m.width-2, 58) // -2 for padding -} - -// calculateAvailableHeight estimates how much height is available for dynamic content -func (m *sidebarCmp) calculateAvailableHeight() int { - usedHeight := 0 - - if !m.compactMode { - if m.height > LogoHeightBreakpoint { - usedHeight += 7 // Approximate logo height - } else { - usedHeight += 2 // Smaller logo height - } - usedHeight += 1 // Empty line after logo - } - - if m.session.ID != "" { - usedHeight += 1 // Title line - usedHeight += 1 // Empty line after title - } - - if !m.compactMode { - usedHeight += 1 // CWD line - usedHeight += 1 // Empty line after CWD - } - - usedHeight += 2 // Model info - - usedHeight += 6 // 3 sections × 2 lines each (header + empty line) - - // Base padding - usedHeight += 2 // Top and bottom padding - - return max(0, m.height-usedHeight) -} - -// getDynamicLimits calculates how many items to show in each section based on available height -func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) { - availableHeight := m.calculateAvailableHeight() - - // If we have very little space, use minimum values - if availableHeight < 10 { - return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection - } - - // Distribute available height among the three sections - // Give priority to files, then LSPs, then MCPs - totalSections := 3 - heightPerSection := availableHeight / totalSections - - // Calculate limits for each section, ensuring minimums - maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection)) - maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection)) - maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection)) - - // If we have extra space, give it to files first - remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs) - if remainingHeight > 0 { - extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles) - maxFiles += extraForFiles - remainingHeight -= extraForFiles - - if remainingHeight > 0 { - extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs) - maxLSPs += extraForLSPs - remainingHeight -= extraForLSPs - - if remainingHeight > 0 { - maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs) - } - } - } - - return maxFiles, maxLSPs, maxMCPs -} - -// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally -func (m *sidebarCmp) renderSectionsHorizontal() string { - // Calculate available width for each section - totalWidth := m.width - 4 // Account for padding and spacing - sectionWidth := min(50, totalWidth/3) - - // Get the sections content with limited height - var filesContent, lspContent, mcpContent string - - filesContent = m.filesBlockCompact(sectionWidth) - lspContent = m.lspBlockCompact(sectionWidth) - mcpContent = m.mcpBlockCompact(sectionWidth) - - return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent) -} - -// filesBlockCompact renders the files block with limited width and height for horizontal layout -func (m *sidebarCmp) filesBlockCompact(maxWidth int) string { - // Convert map to slice and handle type conversion - sessionFiles := slices.Collect(m.files.Seq()) - fileSlice := make([]files.SessionFile, len(sessionFiles)) - for i, sf := range sessionFiles { - fileSlice[i] = files.SessionFile{ - History: files.FileHistory{ - InitialVersion: sf.History.initialVersion, - LatestVersion: sf.History.latestVersion, - }, - FilePath: sf.FilePath, - Additions: sf.Additions, - Deletions: sf.Deletions, - } - } - - // Limit items for horizontal layout - maxItems := min(5, len(fileSlice)) - availableHeight := m.height - 8 // Reserve space for header and other content - if availableHeight > 0 { - maxItems = min(maxItems, availableHeight) - } - - return files.RenderFileBlock(fileSlice, files.RenderOptions{ - MaxWidth: maxWidth, - MaxItems: maxItems, - ShowSection: true, - SectionName: "Modified Files", - }, true) -} - -// lspBlockCompact renders the LSP block with limited width and height for horizontal layout -func (m *sidebarCmp) lspBlockCompact(maxWidth int) string { - // Limit items for horizontal layout - lspConfigs := config.Get().LSP.Sorted() - maxItems := min(5, len(lspConfigs)) - availableHeight := m.height - 8 - if availableHeight > 0 { - maxItems = min(maxItems, availableHeight) - } - - return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{ - MaxWidth: maxWidth, - MaxItems: maxItems, - ShowSection: true, - SectionName: "LSPs", - }, true) -} - -// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout -func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string { - // Limit items for horizontal layout - maxItems := min(5, len(config.Get().MCP.Sorted())) - availableHeight := m.height - 8 - if availableHeight > 0 { - maxItems = min(maxItems, availableHeight) - } - - return mcp.RenderMCPBlock(mcp.RenderOptions{ - MaxWidth: maxWidth, - MaxItems: maxItems, - ShowSection: true, - SectionName: "MCPs", - }, true) -} - -func (m *sidebarCmp) filesBlock() string { - // Convert map to slice and handle type conversion - sessionFiles := slices.Collect(m.files.Seq()) - fileSlice := make([]files.SessionFile, len(sessionFiles)) - for i, sf := range sessionFiles { - fileSlice[i] = files.SessionFile{ - History: files.FileHistory{ - InitialVersion: sf.History.initialVersion, - LatestVersion: sf.History.latestVersion, - }, - FilePath: sf.FilePath, - Additions: sf.Additions, - Deletions: sf.Deletions, - } - } - - // Limit the number of files shown - maxFiles, _, _ := m.getDynamicLimits() - maxFiles = min(len(fileSlice), maxFiles) - - return files.RenderFileBlock(fileSlice, files.RenderOptions{ - MaxWidth: m.getMaxWidth(), - MaxItems: maxFiles, - ShowSection: true, - SectionName: core.Section("Modified Files", m.getMaxWidth()), - }, true) -} - -func (m *sidebarCmp) lspBlock() string { - // Limit the number of LSPs shown - _, maxLSPs, _ := m.getDynamicLimits() - - return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{ - MaxWidth: m.getMaxWidth(), - MaxItems: maxLSPs, - ShowSection: true, - SectionName: core.Section("LSPs", m.getMaxWidth()), - }, true) -} - -func (m *sidebarCmp) mcpBlock() string { - // Limit the number of MCPs shown - _, _, maxMCPs := m.getDynamicLimits() - mcps := config.Get().MCP.Sorted() - maxMCPs = min(len(mcps), maxMCPs) - - return mcp.RenderMCPBlock(mcp.RenderOptions{ - MaxWidth: m.getMaxWidth(), - MaxItems: maxMCPs, - ShowSection: true, - SectionName: core.Section("MCPs", m.getMaxWidth()), - }, true) -} - -func formatTokensAndCost(tokens, contextWindow int64, cost float64) string { - t := styles.CurrentTheme() - // Format tokens in human-readable format (e.g., 110K, 1.2M) - var formattedTokens string - switch { - case tokens >= 1_000_000: - formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) - case tokens >= 1_000: - formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000) - default: - formattedTokens = fmt.Sprintf("%d", tokens) - } - - // Remove .0 suffix if present - if strings.HasSuffix(formattedTokens, ".0K") { - formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1) - } - if strings.HasSuffix(formattedTokens, ".0M") { - formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1) - } - - percentage := (float64(tokens) / float64(contextWindow)) * 100 - - baseStyle := t.S().Base - - formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost)) - - formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens)) - formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage))) - formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens) - if percentage > 80 { - // add the warning icon - formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens) - } - - return fmt.Sprintf("%s %s", formattedTokens, formattedCost) -} - -func (s *sidebarCmp) currentModelBlock() string { - cfg := config.Get() - agentCfg := cfg.Agents[config.AgentCoder] - - selectedModel := cfg.Models[agentCfg.Model] - - model := config.Get().GetModelByType(agentCfg.Model) - - t := styles.CurrentTheme() - - modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon) - modelName := t.S().Text.Render(model.Name) - modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName) - parts := []string{ - modelInfo, - } - if model.CanReason { - reasoningInfoStyle := t.S().Subtle.PaddingLeft(2) - 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"))) - } - } else { - reasoningEffort := model.DefaultReasoningEffort - if selectedModel.ReasoningEffort != "" { - reasoningEffort = selectedModel.ReasoningEffort - } - formatter := cases.Title(language.English, cases.NoLower) - parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort)))) - } - } - if s.session.ID != "" { - parts = append( - parts, - " "+formatTokensAndCost( - s.session.CompletionTokens+s.session.PromptTokens, - model.ContextWindow, - s.session.Cost, - ), - ) - } - return lipgloss.JoinVertical( - lipgloss.Left, - parts..., - ) -} - -// SetSession implements Sidebar. -func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd { - m.session = session - return m.loadSessionFiles -} - -// SetCompactMode sets the compact mode for the sidebar. -func (m *sidebarCmp) SetCompactMode(compact bool) { - m.compactMode = compact -} - -func cwd() string { - cwd := config.Get().WorkingDir() - t := styles.CurrentTheme() - return t.S().Muted.Render(home.Short(cwd)) -} diff --git a/internal/tui/components/chat/splash/keys.go b/internal/tui/components/chat/splash/keys.go deleted file mode 100644 index fc8fc373498feea584e75701010762ac66db7879..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/splash/keys.go +++ /dev/null @@ -1,58 +0,0 @@ -package splash - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - Select, - Next, - Previous, - Yes, - No, - Tab, - LeftRight, - Back, - Copy key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Select: key.NewBinding( - key.WithKeys("enter", "ctrl+y"), - key.WithHelp("enter", "confirm"), - ), - Next: key.NewBinding( - key.WithKeys("down", "ctrl+n"), - key.WithHelp("↓", "next item"), - ), - Previous: key.NewBinding( - key.WithKeys("up", "ctrl+p"), - key.WithHelp("↑", "previous item"), - ), - Yes: key.NewBinding( - key.WithKeys("y", "Y"), - key.WithHelp("y", "yes"), - ), - No: key.NewBinding( - key.WithKeys("n", "N"), - key.WithHelp("n", "no"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "switch"), - ), - LeftRight: key.NewBinding( - key.WithKeys("left", "right"), - key.WithHelp("←/→", "switch"), - ), - Back: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "back"), - ), - Copy: key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "copy url"), - ), - } -} diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go deleted file mode 100644 index 886fe5e530978678246ab120b21e0f943018fd1a..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/splash/splash.go +++ /dev/null @@ -1,874 +0,0 @@ -package splash - -import ( - "fmt" - "strings" - "time" - - "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/crush/internal/agent" - hyperp "github.com/charmbracelet/crush/internal/agent/hyper" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/home" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" - "github.com/charmbracelet/crush/internal/tui/components/logo" - lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp" - "github.com/charmbracelet/crush/internal/tui/components/mcp" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/crush/internal/version" -) - -type Splash interface { - util.Model - layout.Sizeable - layout.Help - Cursor() *tea.Cursor - // SetOnboarding controls whether the splash shows model selection UI - SetOnboarding(bool) - // SetProjectInit controls whether the splash shows project initialization prompt - SetProjectInit(bool) - - // Showing API key input - IsShowingAPIKey() bool - - // IsAPIKeyValid returns whether the API key is valid - IsAPIKeyValid() bool - - // IsShowingClaudeOAuth2 returns whether showing Hyper OAuth2 flow - IsShowingHyperOAuth2() bool - - // IsShowingClaudeOAuth2 returns whether showing GitHub Copilot OAuth2 flow - IsShowingCopilotOAuth2() bool -} - -const ( - SplashScreenPaddingY = 1 // Padding Y for the splash screen - - LogoGap = 6 -) - -// OnboardingCompleteMsg is sent when onboarding is complete -type ( - OnboardingCompleteMsg struct{} - SubmitAPIKeyMsg struct{} -) - -type splashCmp struct { - width, height int - keyMap KeyMap - logoRendered string - - // State - isOnboarding bool - needsProjectInit bool - needsAPIKey bool - selectedNo bool - - listHeight int - modelList *models.ModelListComponent - apiKeyInput *models.APIKeyInput - selectedModel *models.ModelOption - isAPIKeyValid bool - apiKeyValue string - - // Hyper device flow state - hyperDeviceFlow *hyper.DeviceFlow - showHyperDeviceFlow bool - - // Copilot device flow state - copilotDeviceFlow *copilot.DeviceFlow - showCopilotDeviceFlow bool -} - -func New() Splash { - keyMap := DefaultKeyMap() - listKeyMap := list.DefaultKeyMap() - listKeyMap.Down.SetEnabled(false) - listKeyMap.Up.SetEnabled(false) - listKeyMap.HalfPageDown.SetEnabled(false) - listKeyMap.HalfPageUp.SetEnabled(false) - listKeyMap.Home.SetEnabled(false) - listKeyMap.End.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next - listKeyMap.UpOneItem = keyMap.Previous - - modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false) - apiKeyInput := models.NewAPIKeyInput() - - return &splashCmp{ - width: 0, - height: 0, - keyMap: keyMap, - logoRendered: "", - modelList: modelList, - apiKeyInput: apiKeyInput, - selectedNo: false, - } -} - -func (s *splashCmp) SetOnboarding(onboarding bool) { - s.isOnboarding = onboarding -} - -func (s *splashCmp) SetProjectInit(needsInit bool) { - s.needsProjectInit = needsInit -} - -// GetSize implements SplashPage. -func (s *splashCmp) GetSize() (int, int) { - return s.width, s.height -} - -// Init implements SplashPage. -func (s *splashCmp) Init() tea.Cmd { - return tea.Batch( - s.modelList.Init(), - s.apiKeyInput.Init(), - ) -} - -// SetSize implements SplashPage. -func (s *splashCmp) SetSize(width int, height int) tea.Cmd { - wasSmallScreen := s.isSmallScreen() - rerenderLogo := width != s.width - s.height = height - s.width = width - if rerenderLogo || wasSmallScreen != s.isSmallScreen() { - s.logoRendered = s.logoBlock() - } - // remove padding, logo height, gap, title space - s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2 - listWidth := min(60, width) - s.apiKeyInput.SetWidth(width - 2) - return s.modelList.SetSize(listWidth, s.listHeight) -} - -// Update implements SplashPage. -func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - return s, s.SetSize(msg.Width, msg.Height) - case hyper.DeviceFlowCompletedMsg: - s.showHyperDeviceFlow = false - return s, s.saveAPIKeyAndContinue(msg.Token, true) - case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg: - if s.hyperDeviceFlow != nil { - u, cmd := s.hyperDeviceFlow.Update(msg) - s.hyperDeviceFlow = u.(*hyper.DeviceFlow) - return s, cmd - } - return s, nil - case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg: - if s.copilotDeviceFlow != nil { - u, cmd := s.copilotDeviceFlow.Update(msg) - s.copilotDeviceFlow = u.(*copilot.DeviceFlow) - return s, cmd - } - return s, nil - case copilot.DeviceFlowCompletedMsg: - s.showCopilotDeviceFlow = false - return s, s.saveAPIKeyAndContinue(msg.Token, true) - case models.APIKeyStateChangeMsg: - u, cmd := s.apiKeyInput.Update(msg) - s.apiKeyInput = u.(*models.APIKeyInput) - if msg.State == models.APIKeyInputStateVerified { - return s, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { - return SubmitAPIKeyMsg{} - }) - } - return s, cmd - case SubmitAPIKeyMsg: - if s.isAPIKeyValid { - return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true) - } - case tea.KeyPressMsg: - switch { - case key.Matches(msg, s.keyMap.Copy) && s.showHyperDeviceFlow: - return s, s.hyperDeviceFlow.CopyCode() - case key.Matches(msg, s.keyMap.Copy) && s.showCopilotDeviceFlow: - return s, s.copilotDeviceFlow.CopyCode() - case key.Matches(msg, s.keyMap.Back): - switch { - case s.showHyperDeviceFlow: - s.hyperDeviceFlow = nil - s.showHyperDeviceFlow = false - return s, nil - case s.showCopilotDeviceFlow: - s.copilotDeviceFlow = nil - s.showCopilotDeviceFlow = false - return s, nil - case s.isAPIKeyValid: - return s, nil - case s.needsAPIKey: - s.needsAPIKey = false - s.selectedModel = nil - s.isAPIKeyValid = false - s.apiKeyValue = "" - s.apiKeyInput.Reset() - return s, nil - } - case key.Matches(msg, s.keyMap.Select): - switch { - case s.showHyperDeviceFlow: - return s, s.hyperDeviceFlow.CopyCodeAndOpenURL() - case s.showCopilotDeviceFlow: - return s, s.copilotDeviceFlow.CopyCodeAndOpenURL() - case s.isAPIKeyValid: - return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true) - case s.isOnboarding && !s.needsAPIKey: - selectedItem := s.modelList.SelectedModel() - if selectedItem == nil { - return s, nil - } - if s.isProviderConfigured(string(selectedItem.Provider.ID)) { - cmd := s.setPreferredModel(*selectedItem) - s.isOnboarding = false - return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) - } else { - switch selectedItem.Provider.ID { - case hyperp.Name: - s.selectedModel = selectedItem - s.showHyperDeviceFlow = true - s.hyperDeviceFlow = hyper.NewDeviceFlow() - s.hyperDeviceFlow.SetWidth(min(s.width-2, 60)) - return s, s.hyperDeviceFlow.Init() - case catwalk.InferenceProviderCopilot: - if token, ok := config.Get().ImportCopilot(); ok { - s.selectedModel = selectedItem - return s, s.saveAPIKeyAndContinue(token, true) - } - s.selectedModel = selectedItem - s.showCopilotDeviceFlow = true - s.copilotDeviceFlow = copilot.NewDeviceFlow() - s.copilotDeviceFlow.SetWidth(min(s.width-2, 60)) - return s, s.copilotDeviceFlow.Init() - } - // Provider not configured, show API key input - s.needsAPIKey = true - s.selectedModel = selectedItem - s.apiKeyInput.SetProviderName(selectedItem.Provider.Name) - return s, nil - } - case s.needsAPIKey: - // Handle API key submission - s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value()) - if s.apiKeyValue == "" { - return s, nil - } - - provider, err := s.getProvider(s.selectedModel.Provider.ID) - if err != nil || provider == nil { - return s, util.ReportError(fmt.Errorf("provider %s not found", s.selectedModel.Provider.ID)) - } - providerConfig := config.ProviderConfig{ - ID: string(s.selectedModel.Provider.ID), - Name: s.selectedModel.Provider.Name, - APIKey: s.apiKeyValue, - Type: provider.Type, - BaseURL: provider.APIEndpoint, - } - return s, tea.Sequence( - util.CmdHandler(models.APIKeyStateChangeMsg{ - State: models.APIKeyInputStateVerifying, - }), - func() tea.Msg { - start := time.Now() - err := providerConfig.TestConnection(config.Get().Resolver()) - // intentionally wait for at least 750ms to make sure the user sees the spinner - elapsed := time.Since(start) - if elapsed < 750*time.Millisecond { - time.Sleep(750*time.Millisecond - elapsed) - } - if err == nil { - s.isAPIKeyValid = true - return models.APIKeyStateChangeMsg{ - State: models.APIKeyInputStateVerified, - } - } - return models.APIKeyStateChangeMsg{ - State: models.APIKeyInputStateError, - } - }, - ) - case s.needsProjectInit: - return s, s.initializeProject() - } - case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight): - if s.needsAPIKey { - u, cmd := s.apiKeyInput.Update(msg) - s.apiKeyInput = u.(*models.APIKeyInput) - return s, cmd - } - if s.needsProjectInit { - s.selectedNo = !s.selectedNo - return s, nil - } - case key.Matches(msg, s.keyMap.Yes): - if s.needsAPIKey { - u, cmd := s.apiKeyInput.Update(msg) - s.apiKeyInput = u.(*models.APIKeyInput) - return s, cmd - } - if s.isOnboarding { - u, cmd := s.modelList.Update(msg) - s.modelList = u - return s, cmd - } - if s.needsProjectInit { - s.selectedNo = false - return s, s.initializeProject() - } - case key.Matches(msg, s.keyMap.No): - if s.needsAPIKey { - u, cmd := s.apiKeyInput.Update(msg) - s.apiKeyInput = u.(*models.APIKeyInput) - return s, cmd - } - if s.isOnboarding { - u, cmd := s.modelList.Update(msg) - s.modelList = u - return s, cmd - } - if s.needsProjectInit { - s.selectedNo = true - return s, s.initializeProject() - } - default: - switch { - case s.showHyperDeviceFlow: - u, cmd := s.hyperDeviceFlow.Update(msg) - s.hyperDeviceFlow = u.(*hyper.DeviceFlow) - return s, cmd - case s.showCopilotDeviceFlow: - u, cmd := s.copilotDeviceFlow.Update(msg) - s.copilotDeviceFlow = u.(*copilot.DeviceFlow) - return s, cmd - case s.needsAPIKey: - u, cmd := s.apiKeyInput.Update(msg) - s.apiKeyInput = u.(*models.APIKeyInput) - return s, cmd - case s.isOnboarding: - u, cmd := s.modelList.Update(msg) - s.modelList = u - return s, cmd - } - } - case tea.PasteMsg: - switch { - case s.showHyperDeviceFlow: - u, cmd := s.hyperDeviceFlow.Update(msg) - s.hyperDeviceFlow = u.(*hyper.DeviceFlow) - return s, cmd - case s.showCopilotDeviceFlow: - u, cmd := s.copilotDeviceFlow.Update(msg) - s.copilotDeviceFlow = u.(*copilot.DeviceFlow) - return s, cmd - case s.needsAPIKey: - u, cmd := s.apiKeyInput.Update(msg) - s.apiKeyInput = u.(*models.APIKeyInput) - return s, cmd - case s.isOnboarding: - var cmd tea.Cmd - s.modelList, cmd = s.modelList.Update(msg) - return s, cmd - } - case spinner.TickMsg: - switch { - case s.showHyperDeviceFlow: - u, cmd := s.hyperDeviceFlow.Update(msg) - s.hyperDeviceFlow = u.(*hyper.DeviceFlow) - return s, cmd - case s.showCopilotDeviceFlow: - u, cmd := s.copilotDeviceFlow.Update(msg) - s.copilotDeviceFlow = u.(*copilot.DeviceFlow) - return s, cmd - default: - u, cmd := s.apiKeyInput.Update(msg) - s.apiKeyInput = u.(*models.APIKeyInput) - return s, cmd - } - } - return s, nil -} - -func (s *splashCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd { - if s.selectedModel == nil { - return nil - } - - cfg := config.Get() - err := cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey) - if err != nil { - return util.ReportError(fmt.Errorf("failed to save API key: %w", err)) - } - - // Reset API key state and continue with model selection - s.needsAPIKey = false - cmd := s.setPreferredModel(*s.selectedModel) - s.isOnboarding = false - s.selectedModel = nil - s.isAPIKeyValid = false - - if close { - return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) - } - return cmd -} - -func (s *splashCmp) initializeProject() tea.Cmd { - s.needsProjectInit = false - - if err := config.MarkProjectInitialized(); err != nil { - return util.ReportError(err) - } - var cmds []tea.Cmd - - cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{})) - if !s.selectedNo { - initPrompt, err := agent.InitializePrompt(*config.Get()) - if err != nil { - return util.ReportError(err) - } - cmds = append(cmds, - util.CmdHandler(chat.SessionClearedMsg{}), - util.CmdHandler(chat.SendMsg{ - Text: initPrompt, - }), - ) - } - return tea.Sequence(cmds...) -} - -func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd { - cfg := config.Get() - model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID) - if model == nil { - return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID)) - } - - selectedModel := config.SelectedModel{ - Model: selectedItem.Model.ID, - Provider: string(selectedItem.Provider.ID), - ReasoningEffort: model.DefaultReasoningEffort, - MaxTokens: model.DefaultMaxTokens, - } - - err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel) - if err != nil { - return util.ReportError(err) - } - - // Now lets automatically setup the small model - knownProvider, err := s.getProvider(selectedItem.Provider.ID) - if err != nil { - return util.ReportError(err) - } - if knownProvider == nil { - // for local provider we just use the same model - err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel) - if err != nil { - return util.ReportError(err) - } - } else { - smallModel := knownProvider.DefaultSmallModelID - model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel) - // should never happen - if model == nil { - err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel) - if err != nil { - return util.ReportError(err) - } - return nil - } - smallSelectedModel := config.SelectedModel{ - Model: smallModel, - Provider: string(selectedItem.Provider.ID), - ReasoningEffort: model.DefaultReasoningEffort, - MaxTokens: model.DefaultMaxTokens, - } - err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel) - if err != nil { - return util.ReportError(err) - } - } - cfg.SetupAgents() - return nil -} - -func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) { - cfg := config.Get() - providers, err := config.Providers(cfg) - if err != nil { - return nil, err - } - for _, p := range providers { - if p.ID == providerID { - return &p, nil - } - } - return nil, nil -} - -func (s *splashCmp) isProviderConfigured(providerID string) bool { - cfg := config.Get() - if _, ok := cfg.Providers.Get(providerID); ok { - return true - } - return false -} - -func (s *splashCmp) View() string { - t := styles.CurrentTheme() - var content string - - switch { - case s.showHyperDeviceFlow: - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY - hyperView := s.hyperDeviceFlow.View() - hyperSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( - lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Hyper"), - hyperView, - ), - ) - content = lipgloss.JoinVertical( - lipgloss.Left, - s.logoRendered, - hyperSelector, - ) - case s.showCopilotDeviceFlow: - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY - copilotView := s.copilotDeviceFlow.View() - copilotSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( - lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth GitHub Copilot"), - copilotView, - ), - ) - content = lipgloss.JoinVertical( - lipgloss.Left, - s.logoRendered, - copilotSelector, - ) - case s.needsAPIKey: - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY - apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View()) - apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( - lipgloss.JoinVertical( - lipgloss.Left, - apiKeyView, - ), - ) - content = lipgloss.JoinVertical( - lipgloss.Left, - s.logoRendered, - apiKeySelector, - ) - case s.isOnboarding: - modelListView := s.modelList.View() - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY - modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( - lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("To start, let’s choose a provider and model."), - "", - modelListView, - ), - ) - content = lipgloss.JoinVertical( - lipgloss.Left, - s.logoRendered, - modelSelector, - ) - case s.needsProjectInit: - titleStyle := t.S().Base.Foreground(t.FgBase) - pathStyle := t.S().Base.Foreground(t.Success).PaddingLeft(2) - bodyStyle := t.S().Base.Foreground(t.FgMuted) - shortcutStyle := t.S().Base.Foreground(t.Success) - - initFile := config.Get().Options.InitializeAs - initText := lipgloss.JoinVertical( - lipgloss.Left, - titleStyle.Render("Would you like to initialize this project?"), - "", - pathStyle.Render(s.cwd()), - "", - bodyStyle.Render("When I initialize your codebase I examine the project and put the"), - bodyStyle.Render(fmt.Sprintf("result into an %s file which serves as general context.", initFile)), - "", - bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."), - "", - bodyStyle.Render("Would you like to initialize now?"), - ) - - yesButton := core.SelectableButton(core.ButtonOpts{ - Text: "Yep!", - UnderlineIndex: 0, - Selected: !s.selectedNo, - }) - - noButton := core.SelectableButton(core.ButtonOpts{ - Text: "Nope", - UnderlineIndex: 0, - Selected: s.selectedNo, - }) - - buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, " ", noButton) - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - - initContent := t.S().Base.AlignVertical(lipgloss.Bottom).PaddingLeft(1).Height(remainingHeight).Render( - lipgloss.JoinVertical( - lipgloss.Left, - initText, - "", - buttons, - ), - ) - - content = lipgloss.JoinVertical( - lipgloss.Left, - s.logoRendered, - "", - initContent, - ) - default: - parts := []string{ - s.logoRendered, - s.infoSection(), - } - content = lipgloss.JoinVertical(lipgloss.Left, parts...) - } - - return t.S().Base. - Width(s.width). - Height(s.height). - PaddingTop(SplashScreenPaddingY). - PaddingBottom(SplashScreenPaddingY). - Render(content) -} - -func (s *splashCmp) Cursor() *tea.Cursor { - switch { - case s.needsAPIKey: - cursor := s.apiKeyInput.Cursor() - if cursor != nil { - return s.moveCursor(cursor) - } - case s.isOnboarding: - cursor := s.modelList.Cursor() - if cursor != nil { - return s.moveCursor(cursor) - } - } - return nil -} - -func (s *splashCmp) isSmallScreen() bool { - // Consider a screen small if either the width is less than 40 or if the - // height is less than 20 - return s.width < 55 || s.height < 20 -} - -func (s *splashCmp) infoSection() string { - t := styles.CurrentTheme() - infoStyle := t.S().Base.PaddingLeft(2) - if s.isSmallScreen() { - infoStyle = infoStyle.MarginTop(1) - } - return infoStyle.Render( - lipgloss.JoinVertical( - lipgloss.Left, - s.cwdPart(), - "", - s.currentModelBlock(), - "", - lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()), - "", - ), - ) -} - -func (s *splashCmp) logoBlock() string { - t := styles.CurrentTheme() - logoStyle := t.S().Base.Padding(0, 2).Width(s.width) - if s.isSmallScreen() { - // If the width is too small, render a smaller version of the logo - // NOTE: 20 is not correct because [splashCmp.height] is not the - // *actual* window height, instead, it is the height of the splash - // component and that depends on other variables like compact mode and - // the height of the editor. - return logoStyle.Render( - logo.SmallRender(s.width - logoStyle.GetHorizontalFrameSize()), - ) - } - return logoStyle.Render( - logo.Render(version.Version, false, logo.Opts{ - FieldColor: t.Primary, - TitleColorA: t.Secondary, - TitleColorB: t.Primary, - CharmColor: t.Secondary, - VersionColor: t.Primary, - Width: s.width - logoStyle.GetHorizontalFrameSize(), - }), - ) -} - -func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - if cursor == nil { - return nil - } - // Calculate the correct Y offset based on current state - logoHeight := lipgloss.Height(s.logoRendered) - if s.needsAPIKey { - infoSectionHeight := lipgloss.Height(s.infoSection()) - baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight - remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY - offset := baseOffset + remainingHeight - cursor.Y += offset - cursor.X += 1 - } else if s.isOnboarding { - offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2 - cursor.Y += offset - cursor.X += 1 - } - - return cursor -} - -func (s *splashCmp) logoGap() int { - if s.height > 35 { - return LogoGap - } - return 0 -} - -// Bindings implements SplashPage. -func (s *splashCmp) Bindings() []key.Binding { - switch { - case s.needsAPIKey: - return []key.Binding{ - s.keyMap.Select, - s.keyMap.Back, - } - case s.isOnboarding: - return []key.Binding{ - s.keyMap.Select, - s.keyMap.Next, - s.keyMap.Previous, - } - case s.needsProjectInit: - return []key.Binding{ - s.keyMap.Select, - s.keyMap.Yes, - s.keyMap.No, - s.keyMap.Tab, - s.keyMap.LeftRight, - } - default: - return []key.Binding{} - } -} - -func (s *splashCmp) getMaxInfoWidth() int { - return min(s.width-2, 90) // 2 for left padding -} - -func (s *splashCmp) cwdPart() string { - t := styles.CurrentTheme() - maxWidth := s.getMaxInfoWidth() - return t.S().Muted.Width(maxWidth).Render(s.cwd()) -} - -func (s *splashCmp) cwd() string { - return home.Short(config.Get().WorkingDir()) -} - -func LSPList(maxWidth int) []string { - return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{ - MaxWidth: maxWidth, - ShowSection: false, - }) -} - -func (s *splashCmp) lspBlock() string { - t := styles.CurrentTheme() - maxWidth := s.getMaxInfoWidth() / 2 - section := t.S().Subtle.Render("LSPs") - lspList := append([]string{section, ""}, LSPList(maxWidth-1)...) - return t.S().Base.Width(maxWidth).PaddingRight(1).Render( - lipgloss.JoinVertical( - lipgloss.Left, - lspList..., - ), - ) -} - -func MCPList(maxWidth int) []string { - return mcp.RenderMCPList(mcp.RenderOptions{ - MaxWidth: maxWidth, - ShowSection: false, - }) -} - -func (s *splashCmp) mcpBlock() string { - t := styles.CurrentTheme() - maxWidth := s.getMaxInfoWidth() / 2 - section := t.S().Subtle.Render("MCPs") - mcpList := append([]string{section, ""}, MCPList(maxWidth-1)...) - return t.S().Base.Width(maxWidth).PaddingRight(1).Render( - lipgloss.JoinVertical( - lipgloss.Left, - mcpList..., - ), - ) -} - -func (s *splashCmp) currentModelBlock() string { - cfg := config.Get() - agentCfg := cfg.Agents[config.AgentCoder] - model := config.Get().GetModelByType(agentCfg.Model) - if model == nil { - return "" - } - t := styles.CurrentTheme() - modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon) - modelName := t.S().Text.Render(model.Name) - modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName) - parts := []string{ - modelInfo, - } - - return lipgloss.JoinVertical( - lipgloss.Left, - parts..., - ) -} - -func (s *splashCmp) IsShowingAPIKey() bool { - return s.needsAPIKey -} - -func (s *splashCmp) IsAPIKeyValid() bool { - return s.isAPIKeyValid -} - -func (s *splashCmp) IsShowingHyperOAuth2() bool { - return s.showHyperDeviceFlow -} - -func (s *splashCmp) IsShowingCopilotOAuth2() bool { - return s.showCopilotDeviceFlow -} diff --git a/internal/tui/components/chat/todos/todos.go b/internal/tui/components/chat/todos/todos.go deleted file mode 100644 index 8973e4f4675df65c5ff7466665ddf18e74d2203e..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/todos/todos.go +++ /dev/null @@ -1,67 +0,0 @@ -package todos - -import ( - "slices" - "strings" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/x/ansi" -) - -func sortTodos(todos []session.Todo) { - slices.SortStableFunc(todos, func(a, b session.Todo) int { - return statusOrder(a.Status) - statusOrder(b.Status) - }) -} - -func statusOrder(s session.TodoStatus) int { - switch s { - case session.TodoStatusCompleted: - return 0 - case session.TodoStatusInProgress: - return 1 - default: - return 2 - } -} - -func FormatTodosList(todos []session.Todo, inProgressIcon string, t *styles.Theme, width int) string { - if len(todos) == 0 { - return "" - } - - sorted := make([]session.Todo, len(todos)) - copy(sorted, todos) - sortTodos(sorted) - - var lines []string - for _, todo := range sorted { - var prefix string - var textStyle lipgloss.Style - - switch todo.Status { - case session.TodoStatusCompleted: - prefix = t.S().Base.Foreground(t.Green).Render(styles.TodoCompletedIcon) + " " - textStyle = t.S().Base.Foreground(t.FgBase) - case session.TodoStatusInProgress: - prefix = t.S().Base.Foreground(t.GreenDark).Render(inProgressIcon + " ") - textStyle = t.S().Base.Foreground(t.FgBase) - default: - prefix = t.S().Base.Foreground(t.FgMuted).Render(styles.TodoPendingIcon) + " " - textStyle = t.S().Base.Foreground(t.FgBase) - } - - text := todo.Content - if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" { - text = todo.ActiveForm - } - line := prefix + textStyle.Render(text) - line = ansi.Truncate(line, width, "…") - - lines = append(lines, line) - } - - return strings.Join(lines, "\n") -} diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go deleted file mode 100644 index 31532952f6243a466c18d55230875346448c151a..0000000000000000000000000000000000000000 --- a/internal/tui/components/completions/completions.go +++ /dev/null @@ -1,308 +0,0 @@ -package completions - -import ( - "strings" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const maxCompletionsHeight = 10 - -type Completion struct { - Title string // The title of the completion item - Value any // The value of the completion item -} - -type OpenCompletionsMsg struct { - Completions []Completion - X int // X position for the completions popup - Y int // Y position for the completions popup - MaxResults int // Maximum number of results to render, 0 for no limit -} - -type FilterCompletionsMsg struct { - Query string // The query to filter completions - Reopen bool - X int // X position for the completions popup - Y int // Y position for the completions popup -} - -type RepositionCompletionsMsg struct { - X, Y int -} - -type CompletionsClosedMsg struct{} - -type CompletionsOpenedMsg struct{} - -type CloseCompletionsMsg struct{} - -type SelectCompletionMsg struct { - Value any // The value of the selected completion item - Insert bool -} - -type Completions interface { - util.Model - Open() bool - Query() string // Returns the current filter query - KeyMap() KeyMap - Position() (int, int) // Returns the X and Y position of the completions popup - Width() int - Height() int -} - -type listModel = list.FilterableList[list.CompletionItem[any]] - -type completionsCmp struct { - wWidth int // The window width - wHeight int // The window height - width int - lastWidth int - height int // Height of the completions component` - x, xorig int // X position for the completions popup - y int // Y position for the completions popup - open bool // Indicates if the completions are open - keyMap KeyMap - - list listModel - query string // The current filter query -} - -func New() Completions { - completionsKeyMap := DefaultKeyMap() - keyMap := list.DefaultKeyMap() - keyMap.Up.SetEnabled(false) - keyMap.Down.SetEnabled(false) - keyMap.HalfPageDown.SetEnabled(false) - keyMap.HalfPageUp.SetEnabled(false) - keyMap.Home.SetEnabled(false) - keyMap.End.SetEnabled(false) - keyMap.UpOneItem = completionsKeyMap.Up - keyMap.DownOneItem = completionsKeyMap.Down - - l := list.NewFilterableList( - []list.CompletionItem[any]{}, - list.WithFilterInputHidden(), - list.WithFilterListOptions( - list.WithDirectionBackward(), - list.WithKeyMap(keyMap), - ), - ) - return &completionsCmp{ - width: 0, - height: maxCompletionsHeight, - list: l, - query: "", - keyMap: completionsKeyMap, - } -} - -// Init implements Completions. -func (c *completionsCmp) Init() tea.Cmd { - return tea.Sequence( - c.list.Init(), - c.list.SetSize(c.width, c.height), - ) -} - -// Update implements Completions. -func (c *completionsCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - c.wWidth, c.wHeight = msg.Width, msg.Height - return c, nil - case tea.KeyPressMsg: - switch { - case key.Matches(msg, c.keyMap.Up): - u, cmd := c.list.Update(msg) - c.list = u.(listModel) - return c, cmd - - case key.Matches(msg, c.keyMap.Down): - d, cmd := c.list.Update(msg) - c.list = d.(listModel) - return c, cmd - case key.Matches(msg, c.keyMap.UpInsert): - s := c.list.SelectedItem() - if s == nil { - return c, nil - } - selectedItem := *s - c.list.SetSelected(selectedItem.ID()) - return c, util.CmdHandler(SelectCompletionMsg{ - Value: selectedItem.Value(), - Insert: true, - }) - case key.Matches(msg, c.keyMap.DownInsert): - s := c.list.SelectedItem() - if s == nil { - return c, nil - } - selectedItem := *s - c.list.SetSelected(selectedItem.ID()) - return c, util.CmdHandler(SelectCompletionMsg{ - Value: selectedItem.Value(), - Insert: true, - }) - case key.Matches(msg, c.keyMap.Select): - s := c.list.SelectedItem() - if s == nil { - return c, nil - } - selectedItem := *s - c.open = false // Close completions after selection - return c, util.CmdHandler(SelectCompletionMsg{ - Value: selectedItem.Value(), - }) - case key.Matches(msg, c.keyMap.Cancel): - return c, util.CmdHandler(CloseCompletionsMsg{}) - } - case RepositionCompletionsMsg: - c.x, c.y = msg.X, msg.Y - c.adjustPosition() - case CloseCompletionsMsg: - c.open = false - return c, util.CmdHandler(CompletionsClosedMsg{}) - case OpenCompletionsMsg: - c.open = true - c.query = "" - c.x, c.xorig = msg.X, msg.X - c.y = msg.Y - items := []list.CompletionItem[any]{} - t := styles.CurrentTheme() - for _, completion := range msg.Completions { - item := list.NewCompletionItem( - completion.Title, - completion.Value, - list.WithCompletionBackgroundColor(t.BgSubtle), - ) - items = append(items, item) - } - width := listWidth(items) - if len(items) == 0 { - width = listWidth(c.list.Items()) - } - if c.x+width >= c.wWidth { - c.x = c.wWidth - width - 1 - } - c.width = width - c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height - c.list.SetResultsSize(msg.MaxResults) - return c, tea.Batch( - c.list.SetItems(items), - c.list.SetSize(c.width, c.height), - util.CmdHandler(CompletionsOpenedMsg{}), - ) - case FilterCompletionsMsg: - if !c.open && !msg.Reopen { - return c, nil - } - if msg.Query == c.query { - // PERF: if same query, don't need to filter again - return c, nil - } - if len(c.list.Items()) == 0 && - len(msg.Query) > len(c.query) && - strings.HasPrefix(msg.Query, c.query) { - // PERF: if c.query didn't match anything, - // AND msg.Query is longer than c.query, - // AND msg.Query is prefixed with c.query - which means - // that the user typed more chars after a 0 match, - // it won't match anything, so return earlier. - return c, nil - } - c.query = msg.Query - var cmds []tea.Cmd - cmds = append(cmds, c.list.Filter(msg.Query)) - items := c.list.Items() - itemsLen := len(items) - c.xorig = msg.X - c.x, c.y = msg.X, msg.Y - c.adjustPosition() - cmds = append(cmds, c.list.SetSize(c.width, c.height)) - if itemsLen == 0 { - cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{})) - } else if msg.Reopen { - c.open = true - cmds = append(cmds, util.CmdHandler(CompletionsOpenedMsg{})) - } - return c, tea.Batch(cmds...) - } - return c, nil -} - -func (c *completionsCmp) adjustPosition() { - items := c.list.Items() - itemsLen := len(items) - width := listWidth(items) - c.lastWidth = c.width - if c.x < 0 || width < c.lastWidth { - c.x = c.xorig - } else if c.x+width >= c.wWidth { - c.x = c.wWidth - width - 1 - } - c.width = width - c.height = max(min(maxCompletionsHeight, itemsLen), 1) -} - -// View implements Completions. -func (c *completionsCmp) View() string { - if !c.open || len(c.list.Items()) == 0 { - return "" - } - - t := styles.CurrentTheme() - style := t.S().Base. - Width(c.width). - Height(c.height). - Background(t.BgSubtle) - - return style.Render(c.list.View()) -} - -// listWidth returns the width of the last 10 items in the list, which is used -// to determine the width of the completions popup. -// Note this only works for [completionItemCmp] items. -func listWidth(items []list.CompletionItem[any]) int { - var width int - if len(items) == 0 { - return width - } - - for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- { - itemWidth := lipgloss.Width(items[i].Text()) + 2 // +2 for padding - width = max(width, itemWidth) - } - - return width -} - -func (c *completionsCmp) Open() bool { - return c.open -} - -func (c *completionsCmp) Query() string { - return c.query -} - -func (c *completionsCmp) KeyMap() KeyMap { - return c.keyMap -} - -func (c *completionsCmp) Position() (int, int) { - return c.x, c.y - c.height -} - -func (c *completionsCmp) Width() int { - return c.width -} - -func (c *completionsCmp) Height() int { - return c.height -} diff --git a/internal/tui/components/completions/keys.go b/internal/tui/components/completions/keys.go deleted file mode 100644 index 7adaaa02195e5266df0ecb3823fa15d918adb4ab..0000000000000000000000000000000000000000 --- a/internal/tui/components/completions/keys.go +++ /dev/null @@ -1,72 +0,0 @@ -package completions - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - Down, - Up, - Select, - Cancel key.Binding - DownInsert, - UpInsert key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Down: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("down", "move down"), - ), - Up: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("up", "move up"), - ), - Select: key.NewBinding( - key.WithKeys("enter", "tab", "ctrl+y"), - key.WithHelp("enter", "select"), - ), - Cancel: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), - ), - DownInsert: key.NewBinding( - key.WithKeys("ctrl+n"), - key.WithHelp("ctrl+n", "insert next"), - ), - UpInsert: key.NewBinding( - key.WithKeys("ctrl+p"), - key.WithHelp("ctrl+p", "insert previous"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Down, - k.Up, - k.Select, - k.Cancel, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.Up, - k.Down, - } -} diff --git a/internal/tui/components/core/core.go b/internal/tui/components/core/core.go deleted file mode 100644 index 2b60664c26a6082fafd28626d471575b706c9890..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/core.go +++ /dev/null @@ -1,207 +0,0 @@ -package core - -import ( - "image/color" - "strings" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - "charm.land/lipgloss/v2" - "github.com/alecthomas/chroma/v2" - "github.com/charmbracelet/crush/internal/tui/exp/diffview" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/x/ansi" -) - -type KeyMapHelp interface { - Help() help.KeyMap -} - -type simpleHelp struct { - shortList []key.Binding - fullList [][]key.Binding -} - -func NewSimpleHelp(shortList []key.Binding, fullList [][]key.Binding) help.KeyMap { - return &simpleHelp{ - shortList: shortList, - fullList: fullList, - } -} - -// FullHelp implements help.KeyMap. -func (s *simpleHelp) FullHelp() [][]key.Binding { - return s.fullList -} - -// ShortHelp implements help.KeyMap. -func (s *simpleHelp) ShortHelp() []key.Binding { - return s.shortList -} - -func Section(text string, width int) string { - t := styles.CurrentTheme() - char := "─" - length := lipgloss.Width(text) + 1 - remainingWidth := width - length - lineStyle := t.S().Base.Foreground(t.Border) - if remainingWidth > 0 { - text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth)) - } - return text -} - -func SectionWithInfo(text string, width int, info string) string { - t := styles.CurrentTheme() - char := "─" - length := lipgloss.Width(text) + 1 - remainingWidth := width - length - - if info != "" { - remainingWidth -= lipgloss.Width(info) + 1 // 1 for the space before info - } - lineStyle := t.S().Base.Foreground(t.Border) - if remainingWidth > 0 { - text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth)) + " " + info - } - return text -} - -func Title(title string, width int) string { - t := styles.CurrentTheme() - char := "╱" - length := lipgloss.Width(title) + 1 - remainingWidth := width - length - titleStyle := t.S().Base.Foreground(t.Primary) - if remainingWidth > 0 { - lines := strings.Repeat(char, remainingWidth) - lines = styles.ApplyForegroundGrad(lines, t.Primary, t.Secondary) - title = titleStyle.Render(title) + " " + lines - } - return title -} - -type StatusOpts struct { - Icon string // if empty no icon will be shown - Title string - TitleColor color.Color - Description string - DescriptionColor color.Color - ExtraContent string // additional content to append after the description -} - -func Status(opts StatusOpts, width int) string { - t := styles.CurrentTheme() - icon := opts.Icon - title := opts.Title - titleColor := t.FgMuted - if opts.TitleColor != nil { - titleColor = opts.TitleColor - } - description := opts.Description - descriptionColor := t.FgSubtle - if opts.DescriptionColor != nil { - descriptionColor = opts.DescriptionColor - } - title = t.S().Base.Foreground(titleColor).Render(title) - if description != "" { - extraContentWidth := lipgloss.Width(opts.ExtraContent) - if extraContentWidth > 0 { - extraContentWidth += 1 - } - description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…") - description = t.S().Base.Foreground(descriptionColor).Render(description) - } - - content := []string{} - if icon != "" { - content = append(content, icon) - } - content = append(content, title) - if description != "" { - content = append(content, description) - } - if opts.ExtraContent != "" { - content = append(content, opts.ExtraContent) - } - - return strings.Join(content, " ") -} - -type ButtonOpts struct { - Text string - UnderlineIndex int // Index of character to underline (0-based) - Selected bool // Whether this button is selected -} - -// SelectableButton creates a button with an underlined character and selection state -func SelectableButton(opts ButtonOpts) string { - t := styles.CurrentTheme() - - // Base style for the button - buttonStyle := t.S().Text - - // Apply selection styling - if opts.Selected { - buttonStyle = buttonStyle.Foreground(t.White).Background(t.Secondary) - } else { - buttonStyle = buttonStyle.Background(t.BgSubtle) - } - - // Create the button text with underlined character - text := opts.Text - if opts.UnderlineIndex >= 0 && opts.UnderlineIndex < len(text) { - before := text[:opts.UnderlineIndex] - underlined := text[opts.UnderlineIndex : opts.UnderlineIndex+1] - after := text[opts.UnderlineIndex+1:] - - message := buttonStyle.Render(before) + - buttonStyle.Underline(true).Render(underlined) + - buttonStyle.Render(after) - - return buttonStyle.Padding(0, 2).Render(message) - } - - // Fallback if no underline index specified - return buttonStyle.Padding(0, 2).Render(text) -} - -// SelectableButtons creates a horizontal row of selectable buttons -func SelectableButtons(buttons []ButtonOpts, spacing string) string { - if spacing == "" { - spacing = " " - } - - var parts []string - for i, button := range buttons { - parts = append(parts, SelectableButton(button)) - if i < len(buttons)-1 { - parts = append(parts, spacing) - } - } - - return lipgloss.JoinHorizontal(lipgloss.Left, parts...) -} - -// SelectableButtonsVertical creates a vertical row of selectable buttons -func SelectableButtonsVertical(buttons []ButtonOpts, spacing int) string { - var parts []string - for i, button := range buttons { - parts = append(parts, SelectableButton(button)) - if i < len(buttons)-1 { - for range spacing { - parts = append(parts, "") - } - } - } - - return lipgloss.JoinVertical(lipgloss.Center, parts...) -} - -func DiffFormatter() *diffview.DiffView { - t := styles.CurrentTheme() - formatDiff := diffview.New() - style := chroma.MustNewStyle("crush", styles.GetChromaTheme()) - diff := formatDiff.ChromaStyle(style).Style(t.S().Diff).TabWidth(4) - return diff -} diff --git a/internal/tui/components/core/layout/layout.go b/internal/tui/components/core/layout/layout.go deleted file mode 100644 index 99358755d6070286aab00ac13aeb3d3da2b91e3d..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/layout/layout.go +++ /dev/null @@ -1,27 +0,0 @@ -package layout - -import ( - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" -) - -// TODO: move this to core - -type Focusable interface { - Focus() tea.Cmd - Blur() tea.Cmd - IsFocused() bool -} - -type Sizeable interface { - SetSize(width, height int) tea.Cmd - GetSize() (int, int) -} - -type Help interface { - Bindings() []key.Binding -} - -type Positional interface { - SetPosition(x, y int) tea.Cmd -} diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go deleted file mode 100644 index 6d14c5db4c5c343e16bbda7f0846a0fcbfa61b36..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/status/status.go +++ /dev/null @@ -1,113 +0,0 @@ -package status - -import ( - "time" - - "charm.land/bubbles/v2/help" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" -) - -type StatusCmp interface { - util.Model - ToggleFullHelp() - SetKeyMap(keyMap help.KeyMap) -} - -type statusCmp struct { - info util.InfoMsg - width int - messageTTL time.Duration - help help.Model - keyMap help.KeyMap -} - -// clearMessageCmd is a command that clears status messages after a timeout -func (m *statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd { - return tea.Tick(ttl, func(time.Time) tea.Msg { - return util.ClearStatusMsg{} - }) -} - -func (m *statusCmp) Init() tea.Cmd { - return nil -} - -func (m *statusCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.help.SetWidth(msg.Width - 2) - return m, nil - - // Handle status info - case util.InfoMsg: - m.info = msg - ttl := msg.TTL - if ttl == 0 { - ttl = m.messageTTL - } - return m, m.clearMessageCmd(ttl) - case util.ClearStatusMsg: - m.info = util.InfoMsg{} - } - return m, nil -} - -func (m *statusCmp) View() string { - t := styles.CurrentTheme() - status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap)) - if m.info.Msg != "" { - status = m.infoMsg() - } - return status -} - -func (m *statusCmp) infoMsg() string { - t := styles.CurrentTheme() - message := "" - infoType := "" - switch m.info.Type { - case util.InfoTypeError: - infoType = t.S().Base.Background(t.Red).Padding(0, 1).Render("ERROR") - widthLeft := m.width - (lipgloss.Width(infoType) + 2) - info := ansi.Truncate(m.info.Msg, widthLeft, "…") - message = t.S().Base.Background(t.Error).Width(widthLeft+2).Foreground(t.White).Padding(0, 1).Render(info) - case util.InfoTypeWarn: - infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Yellow).Padding(0, 1).Render("WARNING") - widthLeft := m.width - (lipgloss.Width(infoType) + 2) - info := ansi.Truncate(m.info.Msg, widthLeft, "…") - message = t.S().Base.Foreground(t.BgOverlay).Width(widthLeft+2).Background(t.Warning).Padding(0, 1).Render(info) - default: - note := "OKAY!" - if m.info.Type == util.InfoTypeUpdate { - note = "HEY!" - } - infoType = t.S().Base.Foreground(t.BgSubtle).Background(t.Green).Padding(0, 1).Bold(true).Render(note) - widthLeft := m.width - (lipgloss.Width(infoType) + 2) - info := ansi.Truncate(m.info.Msg, widthLeft, "…") - message = t.S().Base.Background(t.GreenDark).Width(widthLeft+2).Foreground(t.BgSubtle).Padding(0, 1).Render(info) - } - return ansi.Truncate(infoType+message, m.width, "…") -} - -func (m *statusCmp) ToggleFullHelp() { - m.help.ShowAll = !m.help.ShowAll -} - -func (m *statusCmp) SetKeyMap(keyMap help.KeyMap) { - m.keyMap = keyMap -} - -func NewStatusCmp() StatusCmp { - t := styles.CurrentTheme() - help := help.New() - help.Styles = t.S().Help - return &statusCmp{ - messageTTL: 5 * time.Second, - help: help, - } -} diff --git a/internal/tui/components/core/status_test.go b/internal/tui/components/core/status_test.go deleted file mode 100644 index c82fc5b2a3e735e1eafd385b74ae5a4877032bd9..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/status_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package core_test - -import ( - "fmt" - "image/color" - "testing" - - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/x/exp/golden" -) - -func TestStatus(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - opts core.StatusOpts - width int - }{ - { - name: "Default", - opts: core.StatusOpts{ - Title: "Status", - Description: "Everything is working fine", - }, - width: 80, - }, - { - name: "WithCustomIcon", - opts: core.StatusOpts{ - Icon: "✓", - Title: "Success", - Description: "Operation completed successfully", - }, - width: 80, - }, - { - name: "NoIcon", - opts: core.StatusOpts{ - Title: "Info", - Description: "This status has no icon", - }, - width: 80, - }, - { - name: "WithColors", - opts: core.StatusOpts{ - Icon: "⚠", - Title: "Warning", - TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow - Description: "This is a warning message", - DescriptionColor: color.RGBA{255, 0, 0, 255}, // Red - }, - width: 80, - }, - { - name: "WithExtraContent", - opts: core.StatusOpts{ - Title: "Build", - Description: "Building project", - ExtraContent: "[2/5]", - }, - width: 80, - }, - { - name: "LongDescription", - opts: core.StatusOpts{ - Title: "Processing", - Description: "This is a very long description that should be truncated when the width is too small to display it completely without wrapping", - }, - width: 60, - }, - { - name: "NarrowWidth", - opts: core.StatusOpts{ - Icon: "●", - Title: "Status", - Description: "Short message", - }, - width: 30, - }, - { - name: "VeryNarrowWidth", - opts: core.StatusOpts{ - Icon: "●", - Title: "Test", - Description: "This will be truncated", - }, - width: 20, - }, - { - name: "EmptyDescription", - opts: core.StatusOpts{ - Icon: "●", - Title: "Title Only", - }, - width: 80, - }, - { - name: "AllFieldsWithExtraContent", - opts: core.StatusOpts{ - Icon: "🚀", - Title: "Deployment", - TitleColor: color.RGBA{0, 0, 255, 255}, // Blue - Description: "Deploying to production environment", - DescriptionColor: color.RGBA{128, 128, 128, 255}, // Gray - ExtraContent: "v1.2.3", - }, - width: 80, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - output := core.Status(tt.opts, tt.width) - golden.RequireEqual(t, []byte(output)) - }) - } -} - -func TestStatusTruncation(t *testing.T) { - t.Parallel() - - opts := core.StatusOpts{ - Icon: "●", - Title: "Very Long Title", - Description: "This is an extremely long description that definitely needs to be truncated", - ExtraContent: "[extra]", - } - - // Test different widths to ensure truncation works correctly - widths := []int{20, 30, 40, 50, 60} - - for _, width := range widths { - t.Run(fmt.Sprintf("Width%d", width), func(t *testing.T) { - t.Parallel() - - output := core.Status(opts, width) - golden.RequireEqual(t, []byte(output)) - }) - } -} diff --git a/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden b/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden deleted file mode 100644 index 89477e3738e6547ea26734e8a49df5d281d70c57..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden +++ /dev/null @@ -1 +0,0 @@ -🚀 Deployment Deploying to production environment v1.2.3 \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/Default.golden b/internal/tui/components/core/testdata/TestStatus/Default.golden deleted file mode 100644 index 2151efd10b7aeb6500b55a0e61fbf5d4a6ef1638..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/Default.golden +++ /dev/null @@ -1 +0,0 @@ -Status Everything is working fine \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden b/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden deleted file mode 100644 index db4acad54383ecbc2cc50061ee5ba77491dc545d..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden +++ /dev/null @@ -1 +0,0 @@ -● Title Only \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/LongDescription.golden b/internal/tui/components/core/testdata/TestStatus/LongDescription.golden deleted file mode 100644 index 13fc6c3335871aaa5513d370d078f8e350571abe..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/LongDescription.golden +++ /dev/null @@ -1 +0,0 @@ -Processing This is a very long description that should be … \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden b/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden deleted file mode 100644 index 0c5b8e93c35e302038e019d58682716b1b220ef7..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden +++ /dev/null @@ -1 +0,0 @@ -● Status Short message \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/NoIcon.golden b/internal/tui/components/core/testdata/TestStatus/NoIcon.golden deleted file mode 100644 index 09e14574c853264a4b18dfafcfac256b38045a02..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/NoIcon.golden +++ /dev/null @@ -1 +0,0 @@ -Info This status has no icon \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden b/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden deleted file mode 100644 index 9bb3917977486b8f862c74db4f43951a9c44a450..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden +++ /dev/null @@ -1 +0,0 @@ -● Test This will be… \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithColors.golden b/internal/tui/components/core/testdata/TestStatus/WithColors.golden deleted file mode 100644 index 97eeb24db9a9803f4d8877296d38a9d878b50fed..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/WithColors.golden +++ /dev/null @@ -1 +0,0 @@ -⚠ Warning This is a warning message \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden b/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden deleted file mode 100644 index 00cf9455b72e0fd3b8fc94e48b09053bb3fde60a..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden +++ /dev/null @@ -1 +0,0 @@ -✓ Success Operation completed successfully \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden b/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden deleted file mode 100644 index 292d1fa97f0400a7c411eff5a658af537fc8b69e..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden +++ /dev/null @@ -1 +0,0 @@ -Build Building project [2/5] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden deleted file mode 100644 index 0df96289f5aa373f174aa9f833478d5c559abe53..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title  [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden deleted file mode 100644 index 56915d1966ab547740910398b101fd70371bb264..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title Thi… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden deleted file mode 100644 index 6b249b2f865698ebc73ed7787daad30ddf417945..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title This is an ex… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden deleted file mode 100644 index 1862198d631f525c3080f7f811ade5a5738658b1..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title This is an extremely lo… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden deleted file mode 100644 index 0f29e46d2660d1bf2584c730c50972e962c4dd32..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title This is an extremely long descrip… [extra] \ No newline at end of file diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go deleted file mode 100644 index 690d29e6c380e46777b57982913132a24c56448f..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/commands/arguments.go +++ /dev/null @@ -1,245 +0,0 @@ -package commands - -import ( - "cmp" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/crush/internal/uicmd" -) - -const ( - argumentsDialogID dialogs.DialogID = "arguments" -) - -// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog. -type ShowArgumentsDialogMsg = uicmd.ShowArgumentsDialogMsg - -// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed. -type CloseArgumentsDialogMsg = uicmd.CloseArgumentsDialogMsg - -// CommandArgumentsDialog represents the commands dialog. -type CommandArgumentsDialog interface { - dialogs.DialogModel -} - -type commandArgumentsDialogCmp struct { - wWidth, wHeight int - width, height int - - inputs []textinput.Model - focused int - keys ArgumentsDialogKeyMap - arguments []Argument - help help.Model - - id string - title string - name string - description string - - onSubmit func(args map[string]string) tea.Cmd -} - -type Argument struct { - Name, Title, Description string - Required bool -} - -func NewCommandArgumentsDialog( - id, title, name, description string, - arguments []Argument, - onSubmit func(args map[string]string) tea.Cmd, -) CommandArgumentsDialog { - t := styles.CurrentTheme() - inputs := make([]textinput.Model, len(arguments)) - - for i, arg := range arguments { - ti := textinput.New() - ti.Placeholder = cmp.Or(arg.Description, "Enter value for "+arg.Title) - ti.SetWidth(40) - ti.SetVirtualCursor(false) - ti.Prompt = "" - - ti.SetStyles(t.S().TextInput) - // Only focus the first input initially - if i == 0 { - ti.Focus() - } else { - ti.Blur() - } - - inputs[i] = ti - } - - return &commandArgumentsDialogCmp{ - inputs: inputs, - keys: DefaultArgumentsDialogKeyMap(), - id: id, - name: name, - title: title, - description: description, - arguments: arguments, - width: 60, - help: help.New(), - onSubmit: onSubmit, - } -} - -// Init implements CommandArgumentsDialog. -func (c *commandArgumentsDialogCmp) Init() tea.Cmd { - return nil -} - -// Update implements CommandArgumentsDialog. -func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - c.wWidth = msg.Width - c.wHeight = msg.Height - c.width = min(90, c.wWidth) - c.height = min(15, c.wHeight) - for i := range c.inputs { - c.inputs[i].SetWidth(c.width - (paddingHorizontal * 2)) - } - case tea.KeyPressMsg: - switch { - case key.Matches(msg, c.keys.Close): - return c, util.CmdHandler(dialogs.CloseDialogMsg{}) - case key.Matches(msg, c.keys.Confirm): - if c.focused == len(c.inputs)-1 { - args := make(map[string]string) - for i, arg := range c.arguments { - value := c.inputs[i].Value() - args[arg.Name] = value - } - return c, tea.Sequence( - util.CmdHandler(dialogs.CloseDialogMsg{}), - c.onSubmit(args), - ) - } - // Otherwise, move to the next input - c.inputs[c.focused].Blur() - c.focused++ - c.inputs[c.focused].Focus() - case key.Matches(msg, c.keys.Next): - // Move to the next input - c.inputs[c.focused].Blur() - c.focused = (c.focused + 1) % len(c.inputs) - c.inputs[c.focused].Focus() - case key.Matches(msg, c.keys.Previous): - // Move to the previous input - c.inputs[c.focused].Blur() - c.focused = (c.focused - 1 + len(c.inputs)) % len(c.inputs) - c.inputs[c.focused].Focus() - case key.Matches(msg, c.keys.Close): - return c, util.CmdHandler(dialogs.CloseDialogMsg{}) - default: - var cmd tea.Cmd - c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg) - return c, cmd - } - case tea.PasteMsg: - var cmd tea.Cmd - c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg) - return c, cmd - } - return c, nil -} - -// View implements CommandArgumentsDialog. -func (c *commandArgumentsDialogCmp) View() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - - title := lipgloss.NewStyle(). - Foreground(t.Primary). - Bold(true). - Padding(0, 1). - Render(cmp.Or(c.title, c.name)) - - promptName := t.S().Text. - Padding(0, 1). - Render(c.description) - - inputFields := make([]string, len(c.inputs)) - for i, input := range c.inputs { - labelStyle := baseStyle.Padding(1, 1, 0, 1) - - if i == c.focused { - labelStyle = labelStyle.Foreground(t.FgBase).Bold(true) - } else { - labelStyle = labelStyle.Foreground(t.FgMuted) - } - - arg := c.arguments[i] - argName := cmp.Or(arg.Title, arg.Name) - if arg.Required { - argName += "*" - } - label := labelStyle.Render(argName + ":") - - field := t.S().Text. - Padding(0, 1). - Render(input.View()) - - inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field) - } - - elements := []string{title, promptName} - elements = append(elements, inputFields...) - - c.help.ShowAll = false - helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys)) - elements = append(elements, "", helpText) - - content := lipgloss.JoinVertical(lipgloss.Left, elements...) - - return baseStyle.Padding(1, 1, 0, 1). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus). - Width(c.width). - Render(content) -} - -func (c *commandArgumentsDialogCmp) Cursor() *tea.Cursor { - if len(c.inputs) == 0 { - return nil - } - cursor := c.inputs[c.focused].Cursor() - if cursor != nil { - cursor = c.moveCursor(cursor) - } - return cursor -} - -const ( - headerHeight = 3 - itemHeight = 3 - paddingHorizontal = 3 -) - -func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - row, col := c.Position() - offset := row + headerHeight + (1+c.focused)*itemHeight - cursor.Y += offset - cursor.X = cursor.X + col + paddingHorizontal - return cursor -} - -func (c *commandArgumentsDialogCmp) Position() (int, int) { - row := (c.wHeight / 2) - (c.height / 2) - col := (c.wWidth / 2) - (c.width / 2) - return row, col -} - -// ID implements CommandArgumentsDialog. -func (c *commandArgumentsDialogCmp) ID() dialogs.DialogID { - return argumentsDialogID -} diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go deleted file mode 100644 index 3c86c984561f96350b2b621c15ae14be9649ae36..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/commands/commands.go +++ /dev/null @@ -1,479 +0,0 @@ -package commands - -import ( - "fmt" - "os" - "slices" - "strings" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/charmbracelet/crush/internal/agent" - "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/pubsub" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/crush/internal/uicmd" -) - -const ( - CommandsDialogID dialogs.DialogID = "commands" - - defaultWidth int = 70 -) - -type commandType = uicmd.CommandType - -const ( - SystemCommands = uicmd.SystemCommands - UserCommands = uicmd.UserCommands - MCPPrompts = uicmd.MCPPrompts -) - -type listModel = list.FilterableList[list.CompletionItem[Command]] - -// Command represents a command that can be executed -type ( - Command = uicmd.Command - CommandRunCustomMsg = uicmd.CommandRunCustomMsg - ShowMCPPromptArgumentsDialogMsg = uicmd.ShowMCPPromptArgumentsDialogMsg -) - -// CommandsDialog represents the commands dialog. -type CommandsDialog interface { - dialogs.DialogModel -} - -type commandDialogCmp struct { - width int - wWidth int // Width of the terminal window - wHeight int // Height of the terminal window - - commandList listModel - keyMap CommandsDialogKeyMap - help help.Model - selected commandType // Selected SystemCommands, UserCommands, or MCPPrompts - userCommands []Command // User-defined commands - mcpPrompts *csync.Slice[Command] // MCP prompts - sessionID string // Current session ID -} - -type ( - SwitchSessionsMsg struct{} - NewSessionsMsg struct{} - SwitchModelMsg struct{} - QuitMsg struct{} - OpenFilePickerMsg struct{} - ToggleHelpMsg struct{} - ToggleCompactModeMsg struct{} - ToggleThinkingMsg struct{} - OpenReasoningDialogMsg struct{} - OpenExternalEditorMsg struct{} - ToggleYoloModeMsg struct{} - CompactMsg struct { - SessionID string - } -) - -func NewCommandDialog(sessionID string) CommandsDialog { - keyMap := DefaultCommandsDialogKeyMap() - listKeyMap := list.DefaultKeyMap() - listKeyMap.Down.SetEnabled(false) - listKeyMap.Up.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next - listKeyMap.UpOneItem = keyMap.Previous - - t := styles.CurrentTheme() - inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) - commandList := list.NewFilterableList( - []list.CompletionItem[Command]{}, - list.WithFilterInputStyle(inputStyle), - list.WithFilterListOptions( - list.WithKeyMap(listKeyMap), - list.WithWrapNavigation(), - list.WithResizeByList(), - ), - ) - help := help.New() - help.Styles = t.S().Help - return &commandDialogCmp{ - commandList: commandList, - width: defaultWidth, - keyMap: DefaultCommandsDialogKeyMap(), - help: help, - selected: SystemCommands, - sessionID: sessionID, - mcpPrompts: csync.NewSlice[Command](), - } -} - -func (c *commandDialogCmp) Init() tea.Cmd { - commands, err := uicmd.LoadCustomCommands() - if err != nil { - return util.ReportError(err) - } - c.userCommands = commands - c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts()) - return c.setCommandType(c.selected) -} - -func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - c.wWidth = msg.Width - c.wHeight = msg.Height - return c, tea.Batch( - c.setCommandType(c.selected), - c.commandList.SetSize(c.listWidth(), c.listHeight()), - ) - case pubsub.Event[mcp.Event]: - // Reload MCP prompts when MCP state changes - if msg.Type == pubsub.UpdatedEvent { - c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts()) - // If we're currently viewing MCP prompts, refresh the list - if c.selected == MCPPrompts { - return c, c.setCommandType(MCPPrompts) - } - return c, nil - } - case tea.KeyPressMsg: - switch { - case key.Matches(msg, c.keyMap.Select): - selectedItem := c.commandList.SelectedItem() - if selectedItem == nil { - return c, nil // No item selected, do nothing - } - command := (*selectedItem).Value() - return c, tea.Sequence( - util.CmdHandler(dialogs.CloseDialogMsg{}), - command.Handler(command), - ) - case key.Matches(msg, c.keyMap.Tab): - if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 { - return c, nil - } - return c, c.setCommandType(c.next()) - case key.Matches(msg, c.keyMap.Close): - return c, util.CmdHandler(dialogs.CloseDialogMsg{}) - default: - u, cmd := c.commandList.Update(msg) - c.commandList = u.(listModel) - return c, cmd - } - } - return c, nil -} - -func (c *commandDialogCmp) next() commandType { - switch c.selected { - case SystemCommands: - if len(c.userCommands) > 0 { - return UserCommands - } - if c.mcpPrompts.Len() > 0 { - return MCPPrompts - } - fallthrough - case UserCommands: - if c.mcpPrompts.Len() > 0 { - return MCPPrompts - } - fallthrough - case MCPPrompts: - return SystemCommands - default: - return SystemCommands - } -} - -func (c *commandDialogCmp) View() string { - t := styles.CurrentTheme() - listView := c.commandList - radio := c.commandTypeRadio() - - header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio) - if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 { - header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4)) - } - content := lipgloss.JoinVertical( - lipgloss.Left, - header, - listView.View(), - "", - t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)), - ) - return c.style().Render(content) -} - -func (c *commandDialogCmp) Cursor() *tea.Cursor { - if cursor, ok := c.commandList.(util.Cursor); ok { - cursor := cursor.Cursor() - if cursor != nil { - cursor = c.moveCursor(cursor) - } - return cursor - } - return nil -} - -func (c *commandDialogCmp) commandTypeRadio() string { - t := styles.CurrentTheme() - - fn := func(i commandType) string { - if i == c.selected { - return "◉ " + i.String() - } - return "○ " + i.String() - } - - parts := []string{ - fn(SystemCommands), - } - if len(c.userCommands) > 0 { - parts = append(parts, fn(UserCommands)) - } - if c.mcpPrompts.Len() > 0 { - parts = append(parts, fn(MCPPrompts)) - } - return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " ")) -} - -func (c *commandDialogCmp) listWidth() int { - return defaultWidth - 2 // 4 for padding -} - -func (c *commandDialogCmp) setCommandType(commandType commandType) tea.Cmd { - c.selected = commandType - - var commands []Command - switch c.selected { - case SystemCommands: - commands = c.defaultCommands() - case UserCommands: - commands = c.userCommands - case MCPPrompts: - commands = slices.Collect(c.mcpPrompts.Seq()) - } - - commandItems := []list.CompletionItem[Command]{} - for _, cmd := range commands { - opts := []list.CompletionItemOption{ - list.WithCompletionID(cmd.ID), - } - if cmd.Shortcut != "" { - opts = append( - opts, - list.WithCompletionShortcut(cmd.Shortcut), - ) - } - commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...)) - } - return c.commandList.SetItems(commandItems) -} - -func (c *commandDialogCmp) listHeight() int { - listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections - return min(listHeigh, c.wHeight/2) -} - -func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - row, col := c.Position() - offset := row + 3 - cursor.Y += offset - cursor.X = cursor.X + col + 2 - return cursor -} - -func (c *commandDialogCmp) style() lipgloss.Style { - t := styles.CurrentTheme() - return t.S().Base. - Width(c.width). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) -} - -func (c *commandDialogCmp) Position() (int, int) { - row := c.wHeight/4 - 2 // just a bit above the center - col := c.wWidth / 2 - col -= c.width / 2 - return row, col -} - -func (c *commandDialogCmp) defaultCommands() []Command { - commands := []Command{ - { - ID: "new_session", - Title: "New Session", - Description: "start a new session", - Shortcut: "ctrl+n", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(NewSessionsMsg{}) - }, - }, - { - ID: "switch_session", - Title: "Switch Session", - Description: "Switch to a different session", - Shortcut: "ctrl+s", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(SwitchSessionsMsg{}) - }, - }, - { - ID: "switch_model", - Title: "Switch Model", - Description: "Switch to a different model", - Shortcut: "ctrl+l", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(SwitchModelMsg{}) - }, - }, - } - - // Only show compact command if there's an active session - if c.sessionID != "" { - commands = append(commands, Command{ - ID: "Summarize", - Title: "Summarize Session", - Description: "Summarize the current session and create a new one with the summary", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(CompactMsg{ - SessionID: c.sessionID, - }) - }, - }) - } - - // Add reasoning toggle for models that support it - cfg := config.Get() - if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok { - providerCfg := cfg.GetProviderForModel(agentCfg.Model) - model := cfg.GetModelByType(agentCfg.Model) - if providerCfg != nil && model != nil && model.CanReason { - selectedModel := cfg.Models[agentCfg.Model] - - // Anthropic models: thinking toggle - if model.CanReason && len(model.ReasoningLevels) == 0 { - status := "Enable" - if selectedModel.Think { - status = "Disable" - } - commands = append(commands, Command{ - ID: "toggle_thinking", - Title: status + " Thinking Mode", - Description: "Toggle model thinking for reasoning-capable models", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(ToggleThinkingMsg{}) - }, - }) - } - - // OpenAI models: reasoning effort dialog - if len(model.ReasoningLevels) > 0 { - commands = append(commands, Command{ - ID: "select_reasoning_effort", - Title: "Select Reasoning Effort", - Description: "Choose reasoning effort level (low/medium/high)", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(OpenReasoningDialogMsg{}) - }, - }) - } - } - } - // Only show toggle compact mode command if window width is larger than compact breakpoint (90) - if c.wWidth > 120 && c.sessionID != "" { - commands = append(commands, Command{ - ID: "toggle_sidebar", - Title: "Toggle Sidebar", - Description: "Toggle between compact and normal layout", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(ToggleCompactModeMsg{}) - }, - }) - } - if c.sessionID != "" { - agentCfg := config.Get().Agents[config.AgentCoder] - model := config.Get().GetModelByType(agentCfg.Model) - if model.SupportsImages { - commands = append(commands, Command{ - ID: "file_picker", - Title: "Open File Picker", - Shortcut: "ctrl+f", - Description: "Open file picker", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(OpenFilePickerMsg{}) - }, - }) - } - } - - // Add external editor command if $EDITOR is available - if os.Getenv("EDITOR") != "" { - commands = append(commands, Command{ - ID: "open_external_editor", - Title: "Open External Editor", - Shortcut: "ctrl+o", - Description: "Open external editor to compose message", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(OpenExternalEditorMsg{}) - }, - }) - } - - return append(commands, []Command{ - { - ID: "toggle_yolo", - Title: "Toggle Yolo Mode", - Description: "Toggle yolo mode", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(ToggleYoloModeMsg{}) - }, - }, - { - ID: "toggle_help", - Title: "Toggle Help", - Shortcut: "ctrl+g", - Description: "Toggle help", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(ToggleHelpMsg{}) - }, - }, - { - ID: "init", - Title: "Initialize Project", - Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs), - Handler: func(cmd Command) tea.Cmd { - initPrompt, err := agent.InitializePrompt(*config.Get()) - if err != nil { - return util.ReportError(err) - } - return util.CmdHandler(chat.SendMsg{ - Text: initPrompt, - }) - }, - }, - { - ID: "quit", - Title: "Quit", - Description: "Quit", - Shortcut: "ctrl+c", - Handler: func(cmd Command) tea.Cmd { - return util.CmdHandler(QuitMsg{}) - }, - }, - }...) -} - -func (c *commandDialogCmp) ID() dialogs.DialogID { - return CommandsDialogID -} diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go deleted file mode 100644 index f07f1c5f4a6db353d6d53888a3bf869702bfb24c..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/commands/keys.go +++ /dev/null @@ -1,133 +0,0 @@ -package commands - -import ( - "charm.land/bubbles/v2/key" -) - -type CommandsDialogKeyMap struct { - Select, - Next, - Previous, - Tab, - Close key.Binding -} - -func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap { - return CommandsDialogKeyMap{ - Select: key.NewBinding( - key.WithKeys("enter", "ctrl+y"), - key.WithHelp("enter", "confirm"), - ), - Next: key.NewBinding( - key.WithKeys("down", "ctrl+n"), - key.WithHelp("↓", "next item"), - ), - Previous: key.NewBinding( - key.WithKeys("up", "ctrl+p"), - key.WithHelp("↑", "previous item"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "switch selection"), - ), - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k CommandsDialogKeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Select, - k.Next, - k.Previous, - k.Tab, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k CommandsDialogKeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k CommandsDialogKeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.Tab, - key.NewBinding( - key.WithKeys("down", "up"), - key.WithHelp("↑↓", "choose"), - ), - k.Select, - k.Close, - } -} - -type ArgumentsDialogKeyMap struct { - Confirm key.Binding - Next key.Binding - Previous key.Binding - Close key.Binding -} - -func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap { - return ArgumentsDialogKeyMap{ - Confirm: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "confirm"), - ), - - Next: key.NewBinding( - key.WithKeys("tab", "down"), - key.WithHelp("tab/↓", "next"), - ), - Previous: key.NewBinding( - key.WithKeys("shift+tab", "up"), - key.WithHelp("shift+tab/↑", "previous"), - ), - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Confirm, - k.Next, - k.Previous, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k ArgumentsDialogKeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.Confirm, - k.Next, - k.Previous, - k.Close, - } -} diff --git a/internal/tui/components/dialogs/copilot/device_flow.go b/internal/tui/components/dialogs/copilot/device_flow.go deleted file mode 100644 index d8a2850c3ea151021958a07b350df879d1db4554..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/copilot/device_flow.go +++ /dev/null @@ -1,281 +0,0 @@ -// Package copilot provides the dialog for Copilot device flow authentication. -package copilot - -import ( - "context" - "fmt" - "time" - - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/oauth" - "github.com/charmbracelet/crush/internal/oauth/copilot" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/pkg/browser" -) - -// DeviceFlowState represents the current state of the device flow. -type DeviceFlowState int - -const ( - DeviceFlowStateDisplay DeviceFlowState = iota - DeviceFlowStateSuccess - DeviceFlowStateError - DeviceFlowStateUnavailable -) - -// DeviceAuthInitiatedMsg is sent when the device auth is initiated -// successfully. -type DeviceAuthInitiatedMsg struct { - deviceCode *copilot.DeviceCode -} - -// DeviceFlowCompletedMsg is sent when the device flow completes successfully. -type DeviceFlowCompletedMsg struct { - Token *oauth.Token -} - -// DeviceFlowErrorMsg is sent when the device flow encounters an error. -type DeviceFlowErrorMsg struct { - Error error -} - -// DeviceFlow handles the Copilot device flow authentication. -type DeviceFlow struct { - State DeviceFlowState - width int - deviceCode *copilot.DeviceCode - token *oauth.Token - cancelFunc context.CancelFunc - spinner spinner.Model -} - -// NewDeviceFlow creates a new device flow component. -func NewDeviceFlow() *DeviceFlow { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight) - return &DeviceFlow{ - State: DeviceFlowStateDisplay, - spinner: s, - } -} - -// Init initializes the device flow by calling the device auth API and starting polling. -func (d *DeviceFlow) Init() tea.Cmd { - return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth) -} - -// Update handles messages and state transitions. -func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) { - var cmd tea.Cmd - d.spinner, cmd = d.spinner.Update(msg) - - switch msg := msg.(type) { - case DeviceAuthInitiatedMsg: - return d, tea.Batch(cmd, d.startPolling(msg.deviceCode)) - case DeviceFlowCompletedMsg: - d.State = DeviceFlowStateSuccess - d.token = msg.Token - return d, nil - case DeviceFlowErrorMsg: - switch msg.Error { - case copilot.ErrNotAvailable: - d.State = DeviceFlowStateUnavailable - default: - d.State = DeviceFlowStateError - } - return d, nil - } - - return d, cmd -} - -// View renders the device flow dialog. -func (d *DeviceFlow) View() string { - t := styles.CurrentTheme() - - whiteStyle := lipgloss.NewStyle().Foreground(t.White) - primaryStyle := lipgloss.NewStyle().Foreground(t.Primary) - greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight) - linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true) - errorStyle := lipgloss.NewStyle().Foreground(t.Error) - mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted) - - switch d.State { - case DeviceFlowStateDisplay: - if d.deviceCode == nil { - return lipgloss.NewStyle(). - Margin(0, 1). - Render( - greenStyle.Render(d.spinner.View()) + - mutedStyle.Render("Initializing..."), - ) - } - - instructions := lipgloss.NewStyle(). - Margin(1, 1, 0, 1). - Width(d.width - 2). - Render( - whiteStyle.Render("Press ") + - primaryStyle.Render("enter") + - whiteStyle.Render(" to copy the code below and open the browser."), - ) - - codeBox := lipgloss.NewStyle(). - Width(d.width-2). - Height(7). - Align(lipgloss.Center, lipgloss.Center). - Background(t.BgBaseLighter). - Margin(1). - Render( - lipgloss.NewStyle(). - Bold(true). - Foreground(t.White). - Render(d.deviceCode.UserCode), - ) - - uri := d.deviceCode.VerificationURI - link := lipgloss.NewStyle().Hyperlink(uri, "id=copilot-verify").Render(uri) - url := mutedStyle. - Margin(0, 1). - Width(d.width - 2). - Render("Browser not opening? Refer to\n" + link) - - waiting := greenStyle. - Width(d.width-2). - Margin(1, 1, 0, 1). - Render(d.spinner.View() + "Verifying...") - - return lipgloss.JoinVertical( - lipgloss.Left, - instructions, - codeBox, - url, - waiting, - ) - - case DeviceFlowStateSuccess: - return greenStyle.Margin(0, 1).Render("Authentication successful!") - - case DeviceFlowStateError: - return lipgloss.NewStyle(). - Margin(0, 1). - Width(d.width - 2). - Render(errorStyle.Render("Authentication failed.")) - - case DeviceFlowStateUnavailable: - message := lipgloss.NewStyle(). - Margin(0, 1). - Width(d.width - 2). - Render("GitHub Copilot is unavailable for this account. To signup, go to the following page:") - freeMessage := lipgloss.NewStyle(). - Margin(0, 1). - Width(d.width - 2). - Render("You may be able to request free access if eligible. For more information, see:") - return lipgloss.JoinVertical( - lipgloss.Left, - message, - "", - linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL), - "", - freeMessage, - "", - linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL), - ) - - default: - return "" - } -} - -// SetWidth sets the width of the dialog. -func (d *DeviceFlow) SetWidth(w int) { - d.width = w -} - -// Cursor hides the cursor. -func (d *DeviceFlow) Cursor() *tea.Cursor { return nil } - -// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL. -func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd { - switch d.State { - case DeviceFlowStateDisplay: - return tea.Sequence( - tea.SetClipboard(d.deviceCode.UserCode), - func() tea.Msg { - if err := browser.OpenURL(d.deviceCode.VerificationURI); err != nil { - return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)} - } - return nil - }, - util.ReportInfo("Code copied and URL opened"), - ) - case DeviceFlowStateUnavailable: - return tea.Sequence( - func() tea.Msg { - if err := browser.OpenURL(copilot.SignupURL); err != nil { - return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)} - } - return nil - }, - util.ReportInfo("Code copied and URL opened"), - ) - default: - return nil - } -} - -// CopyCode copies just the user code to the clipboard. -func (d *DeviceFlow) CopyCode() tea.Cmd { - if d.State != DeviceFlowStateDisplay { - return nil - } - return tea.Sequence( - tea.SetClipboard(d.deviceCode.UserCode), - util.ReportInfo("Code copied to clipboard"), - ) -} - -// Cancel cancels the device flow polling. -func (d *DeviceFlow) Cancel() { - if d.cancelFunc != nil { - d.cancelFunc() - } -} - -func (d *DeviceFlow) initiateDeviceAuth() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - deviceCode, err := copilot.RequestDeviceCode(ctx) - if err != nil { - return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)} - } - - d.deviceCode = deviceCode - - return DeviceAuthInitiatedMsg{ - deviceCode: d.deviceCode, - } -} - -// startPolling starts polling for the device token. -func (d *DeviceFlow) startPolling(deviceCode *copilot.DeviceCode) tea.Cmd { - return func() tea.Msg { - ctx, cancel := context.WithCancel(context.Background()) - d.cancelFunc = cancel - - token, err := copilot.PollForToken(ctx, deviceCode) - if err != nil { - if ctx.Err() != nil { - return nil // cancelled, don't report error. - } - return DeviceFlowErrorMsg{Error: err} - } - - return DeviceFlowCompletedMsg{Token: token} - } -} diff --git a/internal/tui/components/dialogs/dialogs.go b/internal/tui/components/dialogs/dialogs.go deleted file mode 100644 index 4dacd56daa8008b42ebe7ede8bdb6c955b27dbe5..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/dialogs.go +++ /dev/null @@ -1,165 +0,0 @@ -package dialogs - -import ( - "slices" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/util" -) - -type DialogID string - -// DialogModel represents a dialog component that can be displayed. -type DialogModel interface { - util.Model - Position() (int, int) - ID() DialogID -} - -// CloseCallback allows dialogs to perform cleanup when closed. -type CloseCallback interface { - Close() tea.Cmd -} - -// OpenDialogMsg is sent to open a new dialog with specified dimensions. -type OpenDialogMsg struct { - Model DialogModel -} - -// CloseDialogMsg is sent to close the topmost dialog. -type CloseDialogMsg struct{} - -// DialogCmp manages a stack of dialogs with keyboard navigation. -type DialogCmp interface { - util.Model - - Dialogs() []DialogModel - HasDialogs() bool - GetLayers() []*lipgloss.Layer - ActiveModel() util.Model - ActiveDialogID() DialogID -} - -type dialogCmp struct { - width, height int - dialogs []DialogModel - idMap map[DialogID]int - keyMap KeyMap -} - -// NewDialogCmp creates a new dialog manager. -func NewDialogCmp() DialogCmp { - return dialogCmp{ - dialogs: []DialogModel{}, - keyMap: DefaultKeyMap(), - idMap: make(map[DialogID]int), - } -} - -func (d dialogCmp) Init() tea.Cmd { - return nil -} - -// Update handles dialog lifecycle and forwards messages to the active dialog. -func (d dialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - var cmds []tea.Cmd - d.width = msg.Width - d.height = msg.Height - for i := range d.dialogs { - u, cmd := d.dialogs[i].Update(msg) - d.dialogs[i] = u.(DialogModel) - cmds = append(cmds, cmd) - } - return d, tea.Batch(cmds...) - case OpenDialogMsg: - return d.handleOpen(msg) - case CloseDialogMsg: - if len(d.dialogs) == 0 { - return d, nil - } - inx := len(d.dialogs) - 1 - dialog := d.dialogs[inx] - delete(d.idMap, dialog.ID()) - d.dialogs = d.dialogs[:len(d.dialogs)-1] - if closeable, ok := dialog.(CloseCallback); ok { - return d, closeable.Close() - } - return d, nil - } - if d.HasDialogs() { - lastIndex := len(d.dialogs) - 1 - u, cmd := d.dialogs[lastIndex].Update(msg) - d.dialogs[lastIndex] = u.(DialogModel) - return d, cmd - } - return d, nil -} - -func (d dialogCmp) View() string { - return "" -} - -func (d dialogCmp) handleOpen(msg OpenDialogMsg) (util.Model, tea.Cmd) { - if d.HasDialogs() { - dialog := d.dialogs[len(d.dialogs)-1] - if dialog.ID() == msg.Model.ID() { - return d, nil // Do not open a dialog if it's already the topmost one - } - if dialog.ID() == "quit" { - return d, nil // Do not open dialogs on top of quit - } - } - // if the dialog is already in the stack make it the last item - if _, ok := d.idMap[msg.Model.ID()]; ok { - existing := d.dialogs[d.idMap[msg.Model.ID()]] - // Reuse the model so we keep the state - msg.Model = existing - d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1) - } - d.idMap[msg.Model.ID()] = len(d.dialogs) - d.dialogs = append(d.dialogs, msg.Model) - var cmds []tea.Cmd - cmd := msg.Model.Init() - cmds = append(cmds, cmd) - _, cmd = msg.Model.Update(tea.WindowSizeMsg{ - Width: d.width, - Height: d.height, - }) - cmds = append(cmds, cmd) - return d, tea.Batch(cmds...) -} - -func (d dialogCmp) Dialogs() []DialogModel { - return d.dialogs -} - -func (d dialogCmp) ActiveModel() util.Model { - if len(d.dialogs) == 0 { - return nil - } - return d.dialogs[len(d.dialogs)-1] -} - -func (d dialogCmp) ActiveDialogID() DialogID { - if len(d.dialogs) == 0 { - return "" - } - return d.dialogs[len(d.dialogs)-1].ID() -} - -func (d dialogCmp) GetLayers() []*lipgloss.Layer { - layers := []*lipgloss.Layer{} - for _, dialog := range d.Dialogs() { - dialogView := dialog.View() - row, col := dialog.Position() - layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row)) - } - return layers -} - -func (d dialogCmp) HasDialogs() bool { - return len(d.dialogs) > 0 -} diff --git a/internal/tui/components/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go deleted file mode 100644 index fd9f85e70d1a100ec33d89219dd4d276459bb6ee..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/filepicker/filepicker.go +++ /dev/null @@ -1,260 +0,0 @@ -package filepicker - -import ( - "fmt" - "net/http" - "os" - "path/filepath" - "strings" - - "charm.land/bubbles/v2/filepicker" - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/home" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/components/image" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const ( - MaxAttachmentSize = int64(5 * 1024 * 1024) // 5MB - FilePickerID = "filepicker" - fileSelectionHeight = 10 - previewHeight = 20 -) - -type FilePickedMsg struct { - Attachment message.Attachment -} - -type FilePicker interface { - dialogs.DialogModel -} - -type model struct { - wWidth int - wHeight int - width int - filePicker filepicker.Model - highlightedFile string - image image.Model - keyMap KeyMap - help help.Model -} - -var AllowedTypes = []string{".jpg", ".jpeg", ".png"} - -func NewFilePickerCmp(workingDir string) FilePicker { - t := styles.CurrentTheme() - fp := filepicker.New() - fp.AllowedTypes = AllowedTypes - - if workingDir != "" { - fp.CurrentDirectory = workingDir - } else { - // Fallback to current working directory, then home directory - if cwd, err := os.Getwd(); err == nil { - fp.CurrentDirectory = cwd - } else { - fp.CurrentDirectory = home.Dir() - } - } - - fp.ShowPermissions = false - fp.ShowSize = false - fp.AutoHeight = false - fp.Styles = t.S().FilePicker - fp.Cursor = "" - fp.SetHeight(fileSelectionHeight) - - image := image.New(1, 1, "") - - help := help.New() - help.Styles = t.S().Help - return &model{ - filePicker: fp, - image: image, - keyMap: DefaultKeyMap(), - help: help, - } -} - -func (m *model) Init() tea.Cmd { - return m.filePicker.Init() -} - -func (m *model) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.wWidth = msg.Width - m.wHeight = msg.Height - m.width = min(70, m.wWidth) - styles := m.filePicker.Styles - styles.Directory = styles.Directory.Width(m.width - 4) - styles.Selected = styles.Selected.PaddingLeft(1).Width(m.width - 4) - styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(m.width - 4) - styles.File = styles.File.Width(m.width) - m.filePicker.Styles = styles - return m, nil - case tea.KeyPressMsg: - if key.Matches(msg, m.keyMap.Close) { - return m, util.CmdHandler(dialogs.CloseDialogMsg{}) - } - if key.Matches(msg, m.filePicker.KeyMap.Back) { - // make sure we don't go back if we are at the home directory - if m.filePicker.CurrentDirectory == home.Dir() { - return m, nil - } - } - } - - var cmd tea.Cmd - var cmds []tea.Cmd - m.filePicker, cmd = m.filePicker.Update(msg) - cmds = append(cmds, cmd) - if m.highlightedFile != m.currentImage() && m.currentImage() != "" { - w, h := m.imagePreviewSize() - cmd = m.image.Redraw(uint(w-2), uint(h-2), m.currentImage()) - cmds = append(cmds, cmd) - } - m.highlightedFile = m.currentImage() - - // Did the user select a file? - if didSelect, path := m.filePicker.DidSelectFile(msg); didSelect { - // Get the path of the selected file. - return m, tea.Sequence( - util.CmdHandler(dialogs.CloseDialogMsg{}), - func() tea.Msg { - isFileLarge, err := IsFileTooBig(path, MaxAttachmentSize) - if err != nil { - return util.ReportError(fmt.Errorf("unable to read the image: %w", err)) - } - if isFileLarge { - return util.ReportError(fmt.Errorf("file too large, max 5MB")) - } - - content, err := os.ReadFile(path) - if err != nil { - return util.ReportError(fmt.Errorf("unable to read the image: %w", err)) - } - - mimeBufferSize := min(512, len(content)) - mimeType := http.DetectContentType(content[:mimeBufferSize]) - fileName := filepath.Base(path) - attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content} - return FilePickedMsg{ - Attachment: attachment, - } - }, - ) - } - m.image, cmd = m.image.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) -} - -func (m *model) View() string { - t := styles.CurrentTheme() - - strs := []string{ - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add Image", m.width-4)), - } - - // hide image preview if the terminal is too small - if x, y := m.imagePreviewSize(); x > 0 && y > 0 { - strs = append(strs, m.imagePreview()) - } - - strs = append( - strs, - m.filePicker.View(), - t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), - ) - - content := lipgloss.JoinVertical( - lipgloss.Left, - strs..., - ) - return m.style().Render(content) -} - -func (m *model) currentImage() string { - for _, ext := range m.filePicker.AllowedTypes { - if strings.HasSuffix(m.filePicker.HighlightedPath(), ext) { - return m.filePicker.HighlightedPath() - } - } - return "" -} - -func (m *model) imagePreview() string { - const padding = 2 - - t := styles.CurrentTheme() - w, h := m.imagePreviewSize() - - if m.currentImage() == "" { - imgPreview := t.S().Base. - Width(w - padding). - Height(h - padding). - Background(t.BgOverlay) - - return m.imagePreviewStyle().Render(imgPreview.Render()) - } - - return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View()) -} - -func (m *model) imagePreviewStyle() lipgloss.Style { - t := styles.CurrentTheme() - return t.S().Base.Padding(1, 1, 1, 1) -} - -func (m *model) imagePreviewSize() (int, int) { - if m.wHeight-fileSelectionHeight-8 > previewHeight { - return m.width - 4, previewHeight - } - return 0, 0 -} - -func (m *model) style() lipgloss.Style { - t := styles.CurrentTheme() - return t.S().Base. - Width(m.width). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) -} - -// ID implements FilePicker. -func (m *model) ID() dialogs.DialogID { - return FilePickerID -} - -// Position implements FilePicker. -func (m *model) Position() (int, int) { - _, imageHeight := m.imagePreviewSize() - dialogHeight := fileSelectionHeight + imageHeight + 4 - row := (m.wHeight - dialogHeight) / 2 - - col := m.wWidth / 2 - col -= m.width / 2 - return row, col -} - -func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) { - fileInfo, err := os.Stat(filePath) - if err != nil { - return false, fmt.Errorf("error getting file info: %w", err) - } - - if fileInfo.Size() > sizeLimit { - return true, nil - } - - return false, nil -} diff --git a/internal/tui/components/dialogs/filepicker/keys.go b/internal/tui/components/dialogs/filepicker/keys.go deleted file mode 100644 index 1fc493ba148e9d48f0348b3f3d49a132ffe60da2..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/filepicker/keys.go +++ /dev/null @@ -1,80 +0,0 @@ -package filepicker - -import ( - "charm.land/bubbles/v2/key" -) - -// KeyMap defines keyboard bindings for dialog management. -type KeyMap struct { - Select, - Down, - Up, - Forward, - Backward, - Close key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Select: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "accept"), - ), - Down: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("down/j", "move down"), - ), - Up: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("up/k", "move up"), - ), - Forward: key.NewBinding( - key.WithKeys("right", "l"), - key.WithHelp("right/l", "move forward"), - ), - Backward: key.NewBinding( - key.WithKeys("left", "h"), - key.WithHelp("left/h", "move backward"), - ), - - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "close/exit"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Select, - k.Down, - k.Up, - k.Forward, - k.Backward, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - key.NewBinding( - key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"), - key.WithHelp("↑↓←→", "navigate"), - ), - k.Select, - k.Close, - } -} diff --git a/internal/tui/components/dialogs/hyper/device_flow.go b/internal/tui/components/dialogs/hyper/device_flow.go deleted file mode 100644 index b88d3aae8a2d1a826d5827c9f4112911602db2a2..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/hyper/device_flow.go +++ /dev/null @@ -1,267 +0,0 @@ -// Package hyper provides the dialog for Hyper device flow authentication. -package hyper - -import ( - "context" - "fmt" - "time" - - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/oauth" - "github.com/charmbracelet/crush/internal/oauth/hyper" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/pkg/browser" -) - -// DeviceFlowState represents the current state of the device flow. -type DeviceFlowState int - -const ( - DeviceFlowStateDisplay DeviceFlowState = iota - DeviceFlowStateSuccess - DeviceFlowStateError -) - -// DeviceAuthInitiatedMsg is sent when the device auth is initiated -// successfully. -type DeviceAuthInitiatedMsg struct { - deviceCode string - expiresIn int -} - -// DeviceFlowCompletedMsg is sent when the device flow completes successfully. -type DeviceFlowCompletedMsg struct { - Token *oauth.Token -} - -// DeviceFlowErrorMsg is sent when the device flow encounters an error. -type DeviceFlowErrorMsg struct { - Error error -} - -// DeviceFlow handles the Hyper device flow authentication. -type DeviceFlow struct { - State DeviceFlowState - width int - deviceCode string - userCode string - verificationURL string - expiresIn int - token *oauth.Token - cancelFunc context.CancelFunc - spinner spinner.Model -} - -// NewDeviceFlow creates a new device flow component. -func NewDeviceFlow() *DeviceFlow { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight) - return &DeviceFlow{ - State: DeviceFlowStateDisplay, - spinner: s, - } -} - -// Init initializes the device flow by calling the device auth API and starting polling. -func (d *DeviceFlow) Init() tea.Cmd { - return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth) -} - -// Update handles messages and state transitions. -func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) { - var cmd tea.Cmd - d.spinner, cmd = d.spinner.Update(msg) - - switch msg := msg.(type) { - case DeviceAuthInitiatedMsg: - // Start polling now that we have the device code. - d.expiresIn = msg.expiresIn - return d, tea.Batch(cmd, d.startPolling(msg.deviceCode)) - case DeviceFlowCompletedMsg: - d.State = DeviceFlowStateSuccess - d.token = msg.Token - return d, nil - case DeviceFlowErrorMsg: - d.State = DeviceFlowStateError - return d, util.ReportError(msg.Error) - } - - return d, cmd -} - -// View renders the device flow dialog. -func (d *DeviceFlow) View() string { - t := styles.CurrentTheme() - - whiteStyle := lipgloss.NewStyle().Foreground(t.White) - primaryStyle := lipgloss.NewStyle().Foreground(t.Primary) - greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight) - linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true) - errorStyle := lipgloss.NewStyle().Foreground(t.Error) - mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted) - - switch d.State { - case DeviceFlowStateDisplay: - if d.userCode == "" { - return lipgloss.NewStyle(). - Margin(0, 1). - Render( - greenStyle.Render(d.spinner.View()) + - mutedStyle.Render("Initializing..."), - ) - } - - instructions := lipgloss.NewStyle(). - Margin(1, 1, 0, 1). - Width(d.width - 2). - Render( - whiteStyle.Render("Press ") + - primaryStyle.Render("enter") + - whiteStyle.Render(" to copy the code below and open the browser."), - ) - - codeBox := lipgloss.NewStyle(). - Width(d.width-2). - Height(7). - Align(lipgloss.Center, lipgloss.Center). - Background(t.BgBaseLighter). - Margin(1). - Render( - lipgloss.NewStyle(). - Bold(true). - Foreground(t.White). - Render(d.userCode), - ) - - link := linkStyle.Hyperlink(d.verificationURL, "id=hyper-verify").Render(d.verificationURL) - url := mutedStyle. - Margin(0, 1). - Width(d.width - 2). - Render("Browser not opening? Refer to\n" + link) - - waiting := greenStyle. - Width(d.width-2). - Margin(1, 1, 0, 1). - Render(d.spinner.View() + "Verifying...") - - return lipgloss.JoinVertical( - lipgloss.Left, - instructions, - codeBox, - url, - waiting, - ) - - case DeviceFlowStateSuccess: - return greenStyle.Margin(0, 1).Render("Authentication successful!") - - case DeviceFlowStateError: - return lipgloss.NewStyle(). - Margin(0, 1). - Width(d.width - 2). - Render(errorStyle.Render("Authentication failed.")) - - default: - return "" - } -} - -// SetWidth sets the width of the dialog. -func (d *DeviceFlow) SetWidth(w int) { - d.width = w -} - -// Cursor hides the cursor. -func (d *DeviceFlow) Cursor() *tea.Cursor { return nil } - -// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL. -func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd { - if d.State != DeviceFlowStateDisplay { - return nil - } - return tea.Sequence( - tea.SetClipboard(d.userCode), - func() tea.Msg { - if err := browser.OpenURL(d.verificationURL); err != nil { - return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)} - } - return nil - }, - util.ReportInfo("Code copied and URL opened"), - ) -} - -// CopyCode copies just the user code to the clipboard. -func (d *DeviceFlow) CopyCode() tea.Cmd { - if d.State != DeviceFlowStateDisplay { - return nil - } - return tea.Sequence( - tea.SetClipboard(d.userCode), - util.ReportInfo("Code copied to clipboard"), - ) -} - -// Cancel cancels the device flow polling. -func (d *DeviceFlow) Cancel() { - if d.cancelFunc != nil { - d.cancelFunc() - } -} - -func (d *DeviceFlow) initiateDeviceAuth() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - authResp, err := hyper.InitiateDeviceAuth(ctx) - if err != nil { - return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)} - } - - d.deviceCode = authResp.DeviceCode - d.userCode = authResp.UserCode - d.verificationURL = authResp.VerificationURL - - return DeviceAuthInitiatedMsg{ - deviceCode: authResp.DeviceCode, - expiresIn: authResp.ExpiresIn, - } -} - -// startPolling starts polling for the device token. -func (d *DeviceFlow) startPolling(deviceCode string) tea.Cmd { - return func() tea.Msg { - ctx, cancel := context.WithCancel(context.Background()) - d.cancelFunc = cancel - - // Poll for refresh token. - refreshToken, err := hyper.PollForToken(ctx, deviceCode, d.expiresIn) - if err != nil { - if ctx.Err() != nil { - // Cancelled, don't report error. - return nil - } - return DeviceFlowErrorMsg{Error: err} - } - - // Exchange refresh token for access token. - token, err := hyper.ExchangeToken(ctx, refreshToken) - if err != nil { - return DeviceFlowErrorMsg{Error: fmt.Errorf("token exchange failed: %w", err)} - } - - // Verify the access token works. - introspect, err := hyper.IntrospectToken(ctx, token.AccessToken) - if err != nil { - return DeviceFlowErrorMsg{Error: fmt.Errorf("token introspection failed: %w", err)} - } - if !introspect.Active { - return DeviceFlowErrorMsg{Error: fmt.Errorf("access token is not active")} - } - - return DeviceFlowCompletedMsg{Token: token} - } -} diff --git a/internal/tui/components/dialogs/keys.go b/internal/tui/components/dialogs/keys.go deleted file mode 100644 index 178ea65612a0db8072f21c0a17335d7c627afae4..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/keys.go +++ /dev/null @@ -1,43 +0,0 @@ -package dialogs - -import ( - "charm.land/bubbles/v2/key" -) - -// KeyMap defines keyboard bindings for dialog management. -type KeyMap struct { - Close key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.Close, - } -} diff --git a/internal/tui/components/dialogs/models/apikey.go b/internal/tui/components/dialogs/models/apikey.go deleted file mode 100644 index 6ab890ca83bdcce55cc3441683c9b2c6e6acf542..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/apikey.go +++ /dev/null @@ -1,203 +0,0 @@ -package models - -import ( - "fmt" - - "charm.land/bubbles/v2/spinner" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/home" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -type APIKeyInputState int - -const ( - APIKeyInputStateInitial APIKeyInputState = iota - APIKeyInputStateVerifying - APIKeyInputStateVerified - APIKeyInputStateError -) - -type APIKeyStateChangeMsg struct { - State APIKeyInputState -} - -type APIKeyInput struct { - input textinput.Model - width int - spinner spinner.Model - providerName string - state APIKeyInputState - title string - showTitle bool -} - -func NewAPIKeyInput() *APIKeyInput { - t := styles.CurrentTheme() - - ti := textinput.New() - ti.Placeholder = "Enter your API key..." - ti.SetVirtualCursor(false) - ti.Prompt = "> " - ti.SetStyles(t.S().TextInput) - ti.Focus() - - return &APIKeyInput{ - input: ti, - state: APIKeyInputStateInitial, - spinner: spinner.New( - spinner.WithSpinner(spinner.Dot), - spinner.WithStyle(t.S().Base.Foreground(t.Green)), - ), - providerName: "Provider", - showTitle: true, - } -} - -func (a *APIKeyInput) SetProviderName(name string) { - a.providerName = name - a.updateStatePresentation() -} - -func (a *APIKeyInput) SetShowTitle(show bool) { - a.showTitle = show -} - -func (a *APIKeyInput) GetTitle() string { - return a.title -} - -func (a *APIKeyInput) Init() tea.Cmd { - a.updateStatePresentation() - return a.spinner.Tick -} - -func (a *APIKeyInput) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case spinner.TickMsg: - if a.state == APIKeyInputStateVerifying { - var cmd tea.Cmd - a.spinner, cmd = a.spinner.Update(msg) - a.updateStatePresentation() - return a, cmd - } - return a, nil - case APIKeyStateChangeMsg: - a.state = msg.State - var cmd tea.Cmd - if msg.State == APIKeyInputStateVerifying { - cmd = a.spinner.Tick - } - a.updateStatePresentation() - return a, cmd - } - - var cmd tea.Cmd - a.input, cmd = a.input.Update(msg) - return a, cmd -} - -func (a *APIKeyInput) updateStatePresentation() { - t := styles.CurrentTheme() - - prefixStyle := t.S().Base. - Foreground(t.Primary) - accentStyle := t.S().Base.Foreground(t.Green).Bold(true) - errorStyle := t.S().Base.Foreground(t.Cherry) - - switch a.state { - case APIKeyInputStateInitial: - titlePrefix := prefixStyle.Render("Enter your ") - a.title = titlePrefix + accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render(".") - a.input.SetStyles(t.S().TextInput) - a.input.Prompt = "> " - case APIKeyInputStateVerifying: - titlePrefix := prefixStyle.Render("Verifying your ") - a.title = titlePrefix + accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render("...") - ts := t.S().TextInput - // make the blurred state be the same - ts.Blurred.Prompt = ts.Focused.Prompt - a.input.Prompt = a.spinner.View() - a.input.Blur() - case APIKeyInputStateVerified: - a.title = accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render(" validated.") - ts := t.S().TextInput - // make the blurred state be the same - ts.Blurred.Prompt = ts.Focused.Prompt - a.input.SetStyles(ts) - a.input.Prompt = styles.CheckIcon + " " - a.input.Blur() - case APIKeyInputStateError: - a.title = errorStyle.Render("Invalid ") + accentStyle.Render(a.providerName+" API Key") + errorStyle.Render(". Try again?") - ts := t.S().TextInput - ts.Focused.Prompt = ts.Focused.Prompt.Foreground(t.Cherry) - a.input.Focus() - a.input.SetStyles(ts) - a.input.Prompt = styles.ErrorIcon + " " - } -} - -func (a *APIKeyInput) View() string { - inputView := a.input.View() - - dataPath := config.GlobalConfigData() - dataPath = home.Short(dataPath) - helpText := styles.CurrentTheme().S().Muted. - Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath)) - - var content string - if a.showTitle && a.title != "" { - content = lipgloss.JoinVertical( - lipgloss.Left, - a.title, - "", - inputView, - "", - helpText, - ) - } else { - content = lipgloss.JoinVertical( - lipgloss.Left, - inputView, - "", - helpText, - ) - } - - return content -} - -func (a *APIKeyInput) Cursor() *tea.Cursor { - cursor := a.input.Cursor() - if cursor != nil && a.showTitle { - cursor.Y += 2 // Adjust for title and spacing - } - return cursor -} - -func (a *APIKeyInput) Value() string { - return a.input.Value() -} - -func (a *APIKeyInput) Tick() tea.Cmd { - if a.state == APIKeyInputStateVerifying { - return a.spinner.Tick - } - return nil -} - -func (a *APIKeyInput) SetWidth(width int) { - a.width = width - a.input.SetWidth(width - 4) -} - -func (a *APIKeyInput) Reset() { - a.state = APIKeyInputStateInitial - a.input.SetValue("") - a.input.Focus() - a.updateStatePresentation() -} diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go deleted file mode 100644 index ff81404b1f1937fff09d917bf3a9e3b24f4d38c9..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/keys.go +++ /dev/null @@ -1,120 +0,0 @@ -package models - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - Select, - Next, - Previous, - Choose, - Tab, - Close key.Binding - - isAPIKeyHelp bool - isAPIKeyValid bool - - isHyperDeviceFlow bool - isCopilotDeviceFlow bool - isCopilotUnavailable bool -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Select: key.NewBinding( - key.WithKeys("enter", "ctrl+y"), - key.WithHelp("enter", "choose"), - ), - Next: key.NewBinding( - key.WithKeys("down", "ctrl+n"), - key.WithHelp("↓", "next item"), - ), - Previous: key.NewBinding( - key.WithKeys("up", "ctrl+p"), - key.WithHelp("↑", "previous item"), - ), - Choose: key.NewBinding( - key.WithKeys("left", "right", "h", "l"), - key.WithHelp("←→", "choose"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "toggle type"), - ), - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "exit"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Select, - k.Next, - k.Previous, - k.Tab, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - if k.isHyperDeviceFlow || k.isCopilotDeviceFlow { - return []key.Binding{ - key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "copy code"), - ), - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "copy & open"), - ), - k.Close, - } - } - if k.isCopilotUnavailable { - return []key.Binding{ - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "open signup"), - ), - k.Close, - } - } - if k.isAPIKeyHelp && !k.isAPIKeyValid { - return []key.Binding{ - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "submit"), - ), - k.Close, - } - } else if k.isAPIKeyValid { - return []key.Binding{ - k.Select, - } - } - return []key.Binding{ - key.NewBinding( - key.WithKeys("down", "up"), - key.WithHelp("↑↓", "choose"), - ), - k.Tab, - k.Select, - k.Close, - } -} diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go deleted file mode 100644 index 50469a132aab60c3e63a77d9169c47688d5d9151..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/list.go +++ /dev/null @@ -1,333 +0,0 @@ -package models - -import ( - "cmp" - "fmt" - "slices" - "strings" - - tea "charm.land/bubbletea/v2" - "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" - "github.com/charmbracelet/crush/internal/tui/util" -) - -type listModel = list.FilterableGroupList[list.CompletionItem[ModelOption]] - -type ModelListComponent struct { - list listModel - modelType int - providers []catwalk.Provider -} - -func modelKey(providerID, modelID string) string { - if providerID == "" || modelID == "" { - return "" - } - return providerID + ":" + modelID -} - -func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent { - t := styles.CurrentTheme() - inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) - options := []list.ListOption{ - list.WithKeyMap(keyMap), - list.WithWrapNavigation(), - } - if shouldResize { - options = append(options, list.WithResizeByList()) - } - modelList := list.NewFilterableGroupedList( - []list.Group[list.CompletionItem[ModelOption]]{}, - list.WithFilterInputStyle(inputStyle), - list.WithFilterPlaceholder(inputPlaceholder), - list.WithFilterListOptions( - options..., - ), - ) - - return &ModelListComponent{ - list: modelList, - modelType: LargeModelType, - } -} - -func (m *ModelListComponent) Init() tea.Cmd { - var cmds []tea.Cmd - if len(m.providers) == 0 { - cfg := config.Get() - providers, err := config.Providers(cfg) - filteredProviders := []catwalk.Provider{} - for _, p := range providers { - hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$") - isHyper := p.ID == "hyper" - isCopilot := p.ID == catwalk.InferenceProviderCopilot - if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper || isCopilot { - filteredProviders = append(filteredProviders, p) - } - } - - m.providers = filteredProviders - if err != nil { - cmds = append(cmds, util.ReportError(err)) - } - } - cmds = append(cmds, m.list.Init(), m.SetModelType(m.modelType)) - return tea.Batch(cmds...) -} - -func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) { - u, cmd := m.list.Update(msg) - m.list = u.(listModel) - return m, cmd -} - -func (m *ModelListComponent) View() string { - return m.list.View() -} - -func (m *ModelListComponent) Cursor() *tea.Cursor { - return m.list.Cursor() -} - -func (m *ModelListComponent) SetSize(width, height int) tea.Cmd { - return m.list.SetSize(width, height) -} - -func (m *ModelListComponent) SelectedModel() *ModelOption { - s := m.list.SelectedItem() - if s == nil { - return nil - } - sv := *s - model := sv.Value() - return &model -} - -func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { - t := styles.CurrentTheme() - m.modelType = modelType - - var groups []list.Group[list.CompletionItem[ModelOption]] - // first none section - selectedItemID := "" - itemsByKey := make(map[string]list.CompletionItem[ModelOption]) - - cfg := config.Get() - var currentModel config.SelectedModel - selectedType := config.SelectedModelTypeLarge - if m.modelType == LargeModelType { - currentModel = cfg.Models[config.SelectedModelTypeLarge] - selectedType = config.SelectedModelTypeLarge - } else { - currentModel = cfg.Models[config.SelectedModelTypeSmall] - selectedType = config.SelectedModelTypeSmall - } - recentItems := cfg.RecentModels[selectedType] - - configuredIcon := t.S().Base.Foreground(t.Success).Render(styles.CheckIcon) - configured := fmt.Sprintf("%s %s", configuredIcon, t.S().Subtle.Render("Configured")) - - // Create a map to track which providers we've already added - addedProviders := make(map[string]bool) - - // First, add any configured providers that are not in the known providers list - // These should appear at the top of the list - knownProviders, err := config.Providers(cfg) - if err != nil { - return util.ReportError(err) - } - for providerID, providerConfig := range cfg.Providers.Seq2() { - if providerConfig.Disable { - continue - } - - // Check if this provider is not in the known providers list - if !slices.ContainsFunc(knownProviders, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) || - !slices.ContainsFunc(m.providers, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) { - // Convert config provider to provider.Provider format - configProvider := providerConfig.ToProvider() - - // Add this unknown provider to the list - name := configProvider.Name - if name == "" { - name = string(configProvider.ID) - } - section := list.NewItemSection(name) - section.SetInfo(configured) - group := list.Group[list.CompletionItem[ModelOption]]{ - Section: section, - } - for _, model := range configProvider.Models { - modelOption := ModelOption{ - Provider: configProvider, - Model: model, - } - key := modelKey(string(configProvider.ID), model.ID) - item := list.NewCompletionItem( - model.Name, - modelOption, - list.WithCompletionID(key), - ) - itemsByKey[key] = item - - group.Items = append(group.Items, item) - if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider { - selectedItemID = item.ID() - } - } - groups = append(groups, group) - - addedProviders[providerID] = true - } - } - - // Move "Charm Hyper" to first position - // (but still after recent models and custom providers). - slices.SortStableFunc(m.providers, func(a, b catwalk.Provider) int { - switch { - case a.ID == "hyper": - return -1 - case b.ID == "hyper": - return 1 - default: - return 0 - } - }) - - // Then add the known providers from the predefined list - for _, provider := range m.providers { - // Skip if we already added this provider as an unknown provider - if addedProviders[string(provider.ID)] { - continue - } - - providerConfig, providerConfigured := cfg.Providers.Get(string(provider.ID)) - if providerConfigured && providerConfig.Disable { - continue - } - - displayProvider := provider - if providerConfigured { - displayProvider.Name = cmp.Or(providerConfig.Name, displayProvider.Name) - modelIndex := make(map[string]int, len(displayProvider.Models)) - for i, model := range displayProvider.Models { - modelIndex[model.ID] = i - } - for _, model := range providerConfig.Models { - if model.ID == "" { - continue - } - if idx, ok := modelIndex[model.ID]; ok { - if model.Name != "" { - displayProvider.Models[idx].Name = model.Name - } - continue - } - if model.Name == "" { - model.Name = model.ID - } - displayProvider.Models = append(displayProvider.Models, model) - modelIndex[model.ID] = len(displayProvider.Models) - 1 - } - } - - name := displayProvider.Name - if name == "" { - name = string(displayProvider.ID) - } - - section := list.NewItemSection(name) - if providerConfigured { - section.SetInfo(configured) - } - group := list.Group[list.CompletionItem[ModelOption]]{ - Section: section, - } - for _, model := range displayProvider.Models { - modelOption := ModelOption{ - Provider: displayProvider, - Model: model, - } - key := modelKey(string(displayProvider.ID), model.ID) - item := list.NewCompletionItem( - model.Name, - modelOption, - list.WithCompletionID(key), - ) - itemsByKey[key] = item - group.Items = append(group.Items, item) - if model.ID == currentModel.Model && string(displayProvider.ID) == currentModel.Provider { - selectedItemID = item.ID() - } - } - groups = append(groups, group) - } - - if len(recentItems) > 0 { - recentSection := list.NewItemSection("Recently used") - recentGroup := list.Group[list.CompletionItem[ModelOption]]{ - Section: recentSection, - } - var validRecentItems []config.SelectedModel - for _, recent := range recentItems { - key := modelKey(recent.Provider, recent.Model) - option, ok := itemsByKey[key] - if !ok { - continue - } - validRecentItems = append(validRecentItems, recent) - recentID := fmt.Sprintf("recent::%s", key) - modelOption := option.Value() - providerName := modelOption.Provider.Name - if providerName == "" { - providerName = string(modelOption.Provider.ID) - } - item := list.NewCompletionItem( - modelOption.Model.Name, - option.Value(), - list.WithCompletionID(recentID), - list.WithCompletionShortcut(providerName), - ) - recentGroup.Items = append(recentGroup.Items, item) - if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider { - selectedItemID = recentID - } - } - - if len(validRecentItems) != len(recentItems) { - if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil { - return util.ReportError(err) - } - } - - if len(recentGroup.Items) > 0 { - groups = append([]list.Group[list.CompletionItem[ModelOption]]{recentGroup}, groups...) - } - } - - var cmds []tea.Cmd - - cmd := m.list.SetGroups(groups) - - if cmd != nil { - cmds = append(cmds, cmd) - } - cmd = m.list.SetSelected(selectedItemID) - if cmd != nil { - cmds = append(cmds, cmd) - } - - return tea.Sequence(cmds...) -} - -// GetModelType returns the current model type -func (m *ModelListComponent) GetModelType() int { - return m.modelType -} - -func (m *ModelListComponent) SetInputPlaceholder(placeholder string) { - m.list.SetInputPlaceholder(placeholder) -} diff --git a/internal/tui/components/dialogs/models/list_recent_test.go b/internal/tui/components/dialogs/models/list_recent_test.go deleted file mode 100644 index 5afdde98502d3d26d46dce00ab1825ca07f36831..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/list_recent_test.go +++ /dev/null @@ -1,369 +0,0 @@ -package models - -import ( - "encoding/json" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - "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" - "github.com/stretchr/testify/require" -) - -// execCmdML runs a tea.Cmd through the ModelListComponent's Update loop. -func execCmdML(t *testing.T, m *ModelListComponent, cmd tea.Cmd) { - t.Helper() - for cmd != nil { - msg := cmd() - var next tea.Cmd - _, next = m.Update(msg) - cmd = next - } -} - -// readConfigJSON reads and unmarshals the JSON config file at path. -func readConfigJSON(t *testing.T, path string) map[string]any { - t.Helper() - baseDir := filepath.Dir(path) - fileName := filepath.Base(path) - b, err := fs.ReadFile(os.DirFS(baseDir), fileName) - require.NoError(t, err) - var out map[string]any - require.NoError(t, json.Unmarshal(b, &out)) - return out -} - -// readRecentModels reads the recent_models section from the config file. -func readRecentModels(t *testing.T, path string) map[string]any { - t.Helper() - out := readConfigJSON(t, path) - rm, ok := out["recent_models"].(map[string]any) - require.True(t, ok) - return rm -} - -func TestModelList_RecentlyUsedSectionAndPrunesInvalid(t *testing.T) { - // Pre-initialize logger to os.DevNull to prevent file lock on Windows. - log.Setup(os.DevNull, false) - - // Isolate config/data paths - cfgDir := t.TempDir() - dataDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", cfgDir) - t.Setenv("XDG_DATA_HOME", dataDir) - - // Pre-seed config so provider auto-update is disabled and we have recents - confPath := filepath.Join(cfgDir, "crush", "crush.json") - require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755)) - initial := map[string]any{ - "options": map[string]any{ - "disable_provider_auto_update": true, - }, - "models": map[string]any{ - "large": map[string]any{ - "model": "m1", - "provider": "p1", - }, - }, - "recent_models": map[string]any{ - "large": []any{ - map[string]any{"model": "m2", "provider": "p1"}, // valid - map[string]any{"model": "x", "provider": "unknown-provider"}, // invalid -> pruned - }, - }, - } - bts, err := json.Marshal(initial) - require.NoError(t, err) - require.NoError(t, os.WriteFile(confPath, bts, 0o644)) - - // Also create empty providers.json to prevent loading real providers - dataConfDir := filepath.Join(dataDir, "crush") - require.NoError(t, os.MkdirAll(dataConfDir, 0o755)) - emptyProviders := []byte("[]") - require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644)) - - // Initialize global config instance (no network due to auto-update disabled) - _, err = config.Init(cfgDir, dataDir, false) - require.NoError(t, err) - - // Build a small provider set for the list component - provider := catwalk.Provider{ - ID: catwalk.InferenceProvider("p1"), - Name: "Provider One", - Models: []catwalk.Model{ - {ID: "m1", Name: "Model One", DefaultMaxTokens: 100}, - {ID: "m2", Name: "Model Two", DefaultMaxTokens: 100}, // recent - }, - } - - // Create and initialize the component with our provider set - listKeyMap := list.DefaultKeyMap() - cmp := NewModelListComponent(listKeyMap, "Find your fave", false) - cmp.providers = []catwalk.Provider{provider} - execCmdML(t, cmp, cmp.Init()) - - // Find all recent items (IDs prefixed with "recent::") and verify pruning - groups := cmp.list.Groups() - require.NotEmpty(t, groups) - var recentItems []list.CompletionItem[ModelOption] - for _, g := range groups { - for _, it := range g.Items { - if strings.HasPrefix(it.ID(), "recent::") { - recentItems = append(recentItems, it) - } - } - } - require.NotEmpty(t, recentItems, "no recent items found") - // Ensure the valid recent (p1:m2) is present and the invalid one is not - foundValid := false - for _, it := range recentItems { - if it.ID() == "recent::p1:m2" { - foundValid = true - } - require.NotEqual(t, "recent::unknown-provider:x", it.ID(), "invalid recent should be pruned") - } - require.True(t, foundValid, "expected valid recent not found") - - // Verify original config in cfgDir remains unchanged - origConfPath := filepath.Join(cfgDir, "crush", "crush.json") - afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath)) - require.NoError(t, err) - var origParsed map[string]any - require.NoError(t, json.Unmarshal(afterOrig, &origParsed)) - origRM := origParsed["recent_models"].(map[string]any) - origLarge := origRM["large"].([]any) - require.Len(t, origLarge, 2, "original config should be unchanged") - - // Config should be rewritten with pruned recents in dataDir - dataConf := filepath.Join(dataDir, "crush", "crush.json") - rm := readRecentModels(t, dataConf) - largeAny, ok := rm["large"].([]any) - require.True(t, ok) - // Ensure that only valid recent(s) remain and the invalid one is removed - found := false - for _, v := range largeAny { - m := v.(map[string]any) - require.NotEqual(t, "unknown-provider", m["provider"], "invalid provider should be pruned") - if m["provider"] == "p1" && m["model"] == "m2" { - found = true - } - } - require.True(t, found, "persisted recents should include p1:m2") -} - -func TestModelList_PrunesInvalidModelWithinValidProvider(t *testing.T) { - // Pre-initialize logger to os.DevNull to prevent file lock on Windows. - log.Setup(os.DevNull, false) - - // Isolate config/data paths - cfgDir := t.TempDir() - dataDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", cfgDir) - t.Setenv("XDG_DATA_HOME", dataDir) - - // Pre-seed config with valid provider but one invalid model - confPath := filepath.Join(cfgDir, "crush", "crush.json") - require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755)) - initial := map[string]any{ - "options": map[string]any{ - "disable_provider_auto_update": true, - }, - "models": map[string]any{ - "large": map[string]any{ - "model": "m1", - "provider": "p1", - }, - }, - "recent_models": map[string]any{ - "large": []any{ - map[string]any{"model": "m1", "provider": "p1"}, // valid - map[string]any{"model": "missing", "provider": "p1"}, // invalid model - }, - }, - } - bts, err := json.Marshal(initial) - require.NoError(t, err) - require.NoError(t, os.WriteFile(confPath, bts, 0o644)) - - // Create empty providers.json - dataConfDir := filepath.Join(dataDir, "crush") - require.NoError(t, os.MkdirAll(dataConfDir, 0o755)) - emptyProviders := []byte("[]") - require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644)) - - // Initialize global config instance - _, err = config.Init(cfgDir, dataDir, false) - require.NoError(t, err) - - // Build provider set that only includes m1, not "missing" - provider := catwalk.Provider{ - ID: catwalk.InferenceProvider("p1"), - Name: "Provider One", - Models: []catwalk.Model{ - {ID: "m1", Name: "Model One", DefaultMaxTokens: 100}, - }, - } - - // Create and initialize component - listKeyMap := list.DefaultKeyMap() - cmp := NewModelListComponent(listKeyMap, "Find your fave", false) - cmp.providers = []catwalk.Provider{provider} - execCmdML(t, cmp, cmp.Init()) - - // Find all recent items - groups := cmp.list.Groups() - require.NotEmpty(t, groups) - var recentItems []list.CompletionItem[ModelOption] - for _, g := range groups { - for _, it := range g.Items { - if strings.HasPrefix(it.ID(), "recent::") { - recentItems = append(recentItems, it) - } - } - } - require.NotEmpty(t, recentItems, "valid recent should exist") - - // Verify the valid recent is present and invalid model is not - foundValid := false - for _, it := range recentItems { - if it.ID() == "recent::p1:m1" { - foundValid = true - } - require.NotEqual(t, "recent::p1:missing", it.ID(), "invalid model should be pruned") - } - require.True(t, foundValid, "valid recent p1:m1 should be present") - - // Verify original config in cfgDir remains unchanged - origConfPath := filepath.Join(cfgDir, "crush", "crush.json") - afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath)) - require.NoError(t, err) - var origParsed map[string]any - require.NoError(t, json.Unmarshal(afterOrig, &origParsed)) - origRM := origParsed["recent_models"].(map[string]any) - origLarge := origRM["large"].([]any) - require.Len(t, origLarge, 2, "original config should be unchanged") - - // Config should be rewritten with pruned recents in dataDir - dataConf := filepath.Join(dataDir, "crush", "crush.json") - rm := readRecentModels(t, dataConf) - largeAny, ok := rm["large"].([]any) - require.True(t, ok) - require.Len(t, largeAny, 1, "should only have one valid model") - // Verify only p1:m1 remains - m := largeAny[0].(map[string]any) - require.Equal(t, "p1", m["provider"]) - require.Equal(t, "m1", m["model"]) -} - -func TestModelKey_EmptyInputs(t *testing.T) { - // Empty provider - require.Equal(t, "", modelKey("", "model")) - // Empty model - require.Equal(t, "", modelKey("provider", "")) - // Both empty - require.Equal(t, "", modelKey("", "")) - // Valid inputs - require.Equal(t, "p:m", modelKey("p", "m")) -} - -func TestModelList_AllRecentsInvalid(t *testing.T) { - // Pre-initialize logger to os.DevNull to prevent file lock on Windows. - log.Setup(os.DevNull, false) - - // Isolate config/data paths - cfgDir := t.TempDir() - dataDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", cfgDir) - t.Setenv("XDG_DATA_HOME", dataDir) - - // Pre-seed config with only invalid recents - confPath := filepath.Join(cfgDir, "crush", "crush.json") - require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755)) - initial := map[string]any{ - "options": map[string]any{ - "disable_provider_auto_update": true, - }, - "models": map[string]any{ - "large": map[string]any{ - "model": "m1", - "provider": "p1", - }, - }, - "recent_models": map[string]any{ - "large": []any{ - map[string]any{"model": "x", "provider": "unknown1"}, - map[string]any{"model": "y", "provider": "unknown2"}, - }, - }, - } - bts, err := json.Marshal(initial) - require.NoError(t, err) - require.NoError(t, os.WriteFile(confPath, bts, 0o644)) - - // Also create empty providers.json and data config - dataConfDir := filepath.Join(dataDir, "crush") - require.NoError(t, os.MkdirAll(dataConfDir, 0o755)) - emptyProviders := []byte("[]") - require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644)) - - // Initialize global config instance with isolated dataDir - _, err = config.Init(cfgDir, dataDir, false) - require.NoError(t, err) - - // Build provider set (doesn't include unknown1 or unknown2) - provider := catwalk.Provider{ - ID: catwalk.InferenceProvider("p1"), - Name: "Provider One", - Models: []catwalk.Model{ - {ID: "m1", Name: "Model One", DefaultMaxTokens: 100}, - }, - } - - // Create and initialize component - listKeyMap := list.DefaultKeyMap() - cmp := NewModelListComponent(listKeyMap, "Find your fave", false) - cmp.providers = []catwalk.Provider{provider} - execCmdML(t, cmp, cmp.Init()) - - // Verify no recent items exist in UI - groups := cmp.list.Groups() - require.NotEmpty(t, groups) - var recentItems []list.CompletionItem[ModelOption] - for _, g := range groups { - for _, it := range g.Items { - if strings.HasPrefix(it.ID(), "recent::") { - recentItems = append(recentItems, it) - } - } - } - require.Empty(t, recentItems, "all invalid recents should be pruned, resulting in no recent section") - - // Verify original config in cfgDir remains unchanged - origConfPath := filepath.Join(cfgDir, "crush", "crush.json") - afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath)) - require.NoError(t, err) - var origParsed map[string]any - require.NoError(t, json.Unmarshal(afterOrig, &origParsed)) - origRM := origParsed["recent_models"].(map[string]any) - origLarge := origRM["large"].([]any) - require.Len(t, origLarge, 2, "original config should be unchanged") - - // Config should be rewritten with empty recents in dataDir - dataConf := filepath.Join(dataDir, "crush", "crush.json") - rm := readRecentModels(t, dataConf) - // When all recents are pruned, the value may be nil or an empty array - largeVal := rm["large"] - if largeVal == nil { - // nil is acceptable - means empty - return - } - largeAny, ok := largeVal.([]any) - require.True(t, ok, "large key should be nil or array") - require.Empty(t, largeAny, "persisted recents should be empty after pruning all invalid entries") -} diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go deleted file mode 100644 index 34f91d060cf7b7a7fd0a3a6fe678a23ed8439530..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/models.go +++ /dev/null @@ -1,549 +0,0 @@ -// Package models provides the model selection dialog for the TUI. -package models - -import ( - "fmt" - "time" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - "charm.land/catwalk/pkg/catwalk" - "charm.land/lipgloss/v2" - hyperp "github.com/charmbracelet/crush/internal/agent/hyper" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const ( - ModelsDialogID dialogs.DialogID = "models" - - defaultWidth = 60 -) - -const ( - LargeModelType int = iota - SmallModelType - - largeModelInputPlaceholder = "Choose a model for large, complex tasks" - smallModelInputPlaceholder = "Choose a model for small, simple tasks" -) - -// ModelSelectedMsg is sent when a model is selected -type ModelSelectedMsg struct { - Model config.SelectedModel - ModelType config.SelectedModelType -} - -// CloseModelDialogMsg is sent when a model is selected -type CloseModelDialogMsg struct{} - -// ModelDialog interface for the model selection dialog -type ModelDialog interface { - dialogs.DialogModel -} - -type ModelOption struct { - Provider catwalk.Provider - Model catwalk.Model -} - -type modelDialogCmp struct { - width int - wWidth int - wHeight int - - modelList *ModelListComponent - keyMap KeyMap - help help.Model - - // API key state - needsAPIKey bool - apiKeyInput *APIKeyInput - selectedModel *ModelOption - selectedModelType config.SelectedModelType - isAPIKeyValid bool - apiKeyValue string - - // Hyper device flow state - hyperDeviceFlow *hyper.DeviceFlow - showHyperDeviceFlow bool - - // Copilot device flow state - copilotDeviceFlow *copilot.DeviceFlow - showCopilotDeviceFlow bool -} - -func NewModelDialogCmp() ModelDialog { - keyMap := DefaultKeyMap() - - listKeyMap := list.DefaultKeyMap() - listKeyMap.Down.SetEnabled(false) - listKeyMap.Up.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next - listKeyMap.UpOneItem = keyMap.Previous - - t := styles.CurrentTheme() - modelList := NewModelListComponent(listKeyMap, largeModelInputPlaceholder, true) - apiKeyInput := NewAPIKeyInput() - apiKeyInput.SetShowTitle(false) - help := help.New() - help.Styles = t.S().Help - - return &modelDialogCmp{ - modelList: modelList, - apiKeyInput: apiKeyInput, - width: defaultWidth, - keyMap: DefaultKeyMap(), - help: help, - } -} - -func (m *modelDialogCmp) Init() tea.Cmd { - return tea.Batch( - m.modelList.Init(), - m.apiKeyInput.Init(), - ) -} - -func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.wWidth = msg.Width - m.wHeight = msg.Height - m.apiKeyInput.SetWidth(m.width - 2) - m.help.SetWidth(m.width - 2) - return m, m.modelList.SetSize(m.listWidth(), m.listHeight()) - case APIKeyStateChangeMsg: - u, cmd := m.apiKeyInput.Update(msg) - m.apiKeyInput = u.(*APIKeyInput) - return m, cmd - case hyper.DeviceFlowCompletedMsg: - return m, m.saveOauthTokenAndContinue(msg.Token, true) - case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg: - if m.hyperDeviceFlow != nil { - u, cmd := m.hyperDeviceFlow.Update(msg) - m.hyperDeviceFlow = u.(*hyper.DeviceFlow) - return m, cmd - } - return m, nil - case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg: - if m.copilotDeviceFlow != nil { - u, cmd := m.copilotDeviceFlow.Update(msg) - m.copilotDeviceFlow = u.(*copilot.DeviceFlow) - return m, cmd - } - return m, nil - case copilot.DeviceFlowCompletedMsg: - return m, m.saveOauthTokenAndContinue(msg.Token, true) - case tea.KeyPressMsg: - switch { - // Handle Hyper device flow keys - case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showHyperDeviceFlow: - return m, m.hyperDeviceFlow.CopyCode() - case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showCopilotDeviceFlow: - return m, m.copilotDeviceFlow.CopyCode() - case key.Matches(msg, m.keyMap.Select): - // If showing device flow, enter copies code and opens URL - if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil { - return m, m.hyperDeviceFlow.CopyCodeAndOpenURL() - } - if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil { - return m, m.copilotDeviceFlow.CopyCodeAndOpenURL() - } - selectedItem := m.modelList.SelectedModel() - if selectedItem == nil { - return m, nil - } - - modelType := config.SelectedModelTypeLarge - if m.modelList.GetModelType() == SmallModelType { - modelType = config.SelectedModelTypeSmall - } - - askForApiKey := func() { - m.keyMap.isAPIKeyHelp = true - m.showHyperDeviceFlow = false - m.showCopilotDeviceFlow = false - m.needsAPIKey = true - m.selectedModel = selectedItem - m.selectedModelType = modelType - m.apiKeyInput.SetProviderName(selectedItem.Provider.Name) - } - - if m.isAPIKeyValid { - return m, m.saveOauthTokenAndContinue(m.apiKeyValue, true) - } - if m.needsAPIKey { - // Handle API key submission - m.apiKeyValue = m.apiKeyInput.Value() - provider, err := m.getProvider(m.selectedModel.Provider.ID) - if err != nil || provider == nil { - return m, util.ReportError(fmt.Errorf("provider %s not found", m.selectedModel.Provider.ID)) - } - providerConfig := config.ProviderConfig{ - ID: string(m.selectedModel.Provider.ID), - Name: m.selectedModel.Provider.Name, - APIKey: m.apiKeyValue, - Type: provider.Type, - BaseURL: provider.APIEndpoint, - } - return m, tea.Sequence( - util.CmdHandler(APIKeyStateChangeMsg{ - State: APIKeyInputStateVerifying, - }), - func() tea.Msg { - start := time.Now() - err := providerConfig.TestConnection(config.Get().Resolver()) - // intentionally wait for at least 750ms to make sure the user sees the spinner - elapsed := time.Since(start) - if elapsed < 750*time.Millisecond { - time.Sleep(750*time.Millisecond - elapsed) - } - if err == nil { - m.isAPIKeyValid = true - return APIKeyStateChangeMsg{ - State: APIKeyInputStateVerified, - } - } - return APIKeyStateChangeMsg{ - State: APIKeyInputStateError, - } - }, - ) - } - - // Check if provider is configured - if m.isProviderConfigured(string(selectedItem.Provider.ID)) { - return m, tea.Sequence( - util.CmdHandler(dialogs.CloseDialogMsg{}), - util.CmdHandler(ModelSelectedMsg{ - Model: config.SelectedModel{ - Model: selectedItem.Model.ID, - Provider: string(selectedItem.Provider.ID), - ReasoningEffort: selectedItem.Model.DefaultReasoningEffort, - MaxTokens: selectedItem.Model.DefaultMaxTokens, - }, - ModelType: modelType, - }), - ) - } - switch selectedItem.Provider.ID { - case hyperp.Name: - m.showHyperDeviceFlow = true - m.selectedModel = selectedItem - m.selectedModelType = modelType - m.hyperDeviceFlow = hyper.NewDeviceFlow() - m.hyperDeviceFlow.SetWidth(m.width - 2) - return m, m.hyperDeviceFlow.Init() - case catwalk.InferenceProviderCopilot: - if token, ok := config.Get().ImportCopilot(); ok { - m.selectedModel = selectedItem - m.selectedModelType = modelType - return m, m.saveOauthTokenAndContinue(token, true) - } - m.showCopilotDeviceFlow = true - m.selectedModel = selectedItem - m.selectedModelType = modelType - m.copilotDeviceFlow = copilot.NewDeviceFlow() - m.copilotDeviceFlow.SetWidth(m.width - 2) - return m, m.copilotDeviceFlow.Init() - } - // For other providers, show API key input - askForApiKey() - return m, nil - case key.Matches(msg, m.keyMap.Tab): - switch { - case m.needsAPIKey: - u, cmd := m.apiKeyInput.Update(msg) - m.apiKeyInput = u.(*APIKeyInput) - return m, cmd - case m.modelList.GetModelType() == LargeModelType: - m.modelList.SetInputPlaceholder(smallModelInputPlaceholder) - return m, m.modelList.SetModelType(SmallModelType) - default: - m.modelList.SetInputPlaceholder(largeModelInputPlaceholder) - return m, m.modelList.SetModelType(LargeModelType) - } - case key.Matches(msg, m.keyMap.Close): - switch { - case m.showHyperDeviceFlow: - if m.hyperDeviceFlow != nil { - m.hyperDeviceFlow.Cancel() - } - m.showHyperDeviceFlow = false - m.selectedModel = nil - case m.showCopilotDeviceFlow: - if m.copilotDeviceFlow != nil { - m.copilotDeviceFlow.Cancel() - } - m.showCopilotDeviceFlow = false - m.selectedModel = nil - case m.needsAPIKey: - if m.isAPIKeyValid { - return m, nil - } - // Go back to model selection - m.needsAPIKey = false - m.selectedModel = nil - m.isAPIKeyValid = false - m.apiKeyValue = "" - m.apiKeyInput.Reset() - return m, nil - default: - return m, util.CmdHandler(dialogs.CloseDialogMsg{}) - } - default: - switch { - case m.needsAPIKey: - u, cmd := m.apiKeyInput.Update(msg) - m.apiKeyInput = u.(*APIKeyInput) - return m, cmd - default: - u, cmd := m.modelList.Update(msg) - m.modelList = u - return m, cmd - } - } - case tea.PasteMsg: - switch { - case m.needsAPIKey: - u, cmd := m.apiKeyInput.Update(msg) - m.apiKeyInput = u.(*APIKeyInput) - return m, cmd - default: - var cmd tea.Cmd - m.modelList, cmd = m.modelList.Update(msg) - return m, cmd - } - case spinner.TickMsg: - u, cmd := m.apiKeyInput.Update(msg) - m.apiKeyInput = u.(*APIKeyInput) - if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil { - u, cmd = m.hyperDeviceFlow.Update(msg) - m.hyperDeviceFlow = u.(*hyper.DeviceFlow) - } - if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil { - u, cmd = m.copilotDeviceFlow.Update(msg) - m.copilotDeviceFlow = u.(*copilot.DeviceFlow) - } - return m, cmd - default: - // Pass all other messages to the device flow for spinner animation - switch { - case m.showHyperDeviceFlow && m.hyperDeviceFlow != nil: - u, cmd := m.hyperDeviceFlow.Update(msg) - m.hyperDeviceFlow = u.(*hyper.DeviceFlow) - return m, cmd - case m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil: - u, cmd := m.copilotDeviceFlow.Update(msg) - m.copilotDeviceFlow = u.(*copilot.DeviceFlow) - return m, cmd - default: - u, cmd := m.apiKeyInput.Update(msg) - m.apiKeyInput = u.(*APIKeyInput) - return m, cmd - } - } - return m, nil -} - -func (m *modelDialogCmp) View() string { - t := styles.CurrentTheme() - - if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil { - // Show Hyper device flow - m.keyMap.isHyperDeviceFlow = true - deviceFlowView := m.hyperDeviceFlow.View() - content := lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with Hyper", m.width-4)), - deviceFlowView, - "", - t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), - ) - return m.style().Render(content) - } - if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil { - // Show Hyper device flow - m.keyMap.isCopilotDeviceFlow = m.copilotDeviceFlow.State != copilot.DeviceFlowStateUnavailable - m.keyMap.isCopilotUnavailable = m.copilotDeviceFlow.State == copilot.DeviceFlowStateUnavailable - deviceFlowView := m.copilotDeviceFlow.View() - content := lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with GitHub Copilot", m.width-4)), - deviceFlowView, - "", - t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), - ) - return m.style().Render(content) - } - - // Reset the flags when not showing device flow - m.keyMap.isHyperDeviceFlow = false - m.keyMap.isCopilotDeviceFlow = false - m.keyMap.isCopilotUnavailable = false - - switch { - case m.needsAPIKey: - // Show API key input - m.keyMap.isAPIKeyHelp = true - m.keyMap.isAPIKeyValid = m.isAPIKeyValid - apiKeyView := m.apiKeyInput.View() - apiKeyView = t.S().Base.Width(m.width - 3).Height(lipgloss.Height(apiKeyView)).PaddingLeft(1).Render(apiKeyView) - content := lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title(m.apiKeyInput.GetTitle(), m.width-4)), - apiKeyView, - "", - t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), - ) - return m.style().Render(content) - } - - // Show model selection - listView := m.modelList.View() - radio := m.modelTypeRadio() - content := lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Model", m.width-lipgloss.Width(radio)-5)+" "+radio), - listView, - "", - t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), - ) - return m.style().Render(content) -} - -func (m *modelDialogCmp) Cursor() *tea.Cursor { - if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil { - return m.hyperDeviceFlow.Cursor() - } - if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil { - return m.copilotDeviceFlow.Cursor() - } - if m.needsAPIKey { - cursor := m.apiKeyInput.Cursor() - if cursor != nil { - cursor = m.moveCursor(cursor) - return cursor - } - } else { - cursor := m.modelList.Cursor() - if cursor != nil { - cursor = m.moveCursor(cursor) - return cursor - } - } - return nil -} - -func (m *modelDialogCmp) style() lipgloss.Style { - t := styles.CurrentTheme() - return t.S().Base. - Width(m.width). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) -} - -func (m *modelDialogCmp) listWidth() int { - return m.width - 2 -} - -func (m *modelDialogCmp) listHeight() int { - return m.wHeight / 2 -} - -func (m *modelDialogCmp) Position() (int, int) { - row := m.wHeight/4 - 2 // just a bit above the center - col := m.wWidth / 2 - col -= m.width / 2 - return row, col -} - -func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - row, col := m.Position() - if m.needsAPIKey { - offset := row + 3 // Border + title + API key input offset - cursor.Y += offset - cursor.X = cursor.X + col + 2 - } else { - offset := row + 3 // Border + title - cursor.Y += offset - cursor.X = cursor.X + col + 2 - } - return cursor -} - -func (m *modelDialogCmp) ID() dialogs.DialogID { - return ModelsDialogID -} - -func (m *modelDialogCmp) modelTypeRadio() string { - t := styles.CurrentTheme() - choices := []string{"Large Task", "Small Task"} - iconSelected := "◉" - iconUnselected := "○" - if m.modelList.GetModelType() == LargeModelType { - return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1]) - } - return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1]) -} - -func (m *modelDialogCmp) isProviderConfigured(providerID string) bool { - cfg := config.Get() - _, ok := cfg.Providers.Get(providerID) - return ok -} - -func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) { - cfg := config.Get() - providers, err := config.Providers(cfg) - if err != nil { - return nil, err - } - for _, p := range providers { - if p.ID == providerID { - return &p, nil - } - } - return nil, nil -} - -func (m *modelDialogCmp) saveOauthTokenAndContinue(apiKey any, close bool) tea.Cmd { - if m.selectedModel == nil { - return util.ReportError(fmt.Errorf("no model selected")) - } - - cfg := config.Get() - err := cfg.SetProviderAPIKey(string(m.selectedModel.Provider.ID), apiKey) - if err != nil { - return util.ReportError(fmt.Errorf("failed to save API key: %w", err)) - } - - // Reset API key state and continue with model selection - selectedModel := *m.selectedModel - var cmds []tea.Cmd - if close { - cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{})) - } - cmds = append( - cmds, - util.CmdHandler(ModelSelectedMsg{ - Model: config.SelectedModel{ - Model: selectedModel.Model.ID, - Provider: string(selectedModel.Provider.ID), - ReasoningEffort: selectedModel.Model.DefaultReasoningEffort, - MaxTokens: selectedModel.Model.DefaultMaxTokens, - }, - ModelType: m.selectedModelType, - }), - ) - return tea.Sequence(cmds...) -} diff --git a/internal/tui/components/dialogs/permissions/keys.go b/internal/tui/components/dialogs/permissions/keys.go deleted file mode 100644 index 5e7786ec1eddf1f3491f3a961c087f72911f1c33..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/permissions/keys.go +++ /dev/null @@ -1,113 +0,0 @@ -package permissions - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - Left, - Right, - Tab, - Select, - Allow, - AllowSession, - Deny, - ToggleDiffMode, - ScrollDown, - ScrollUp key.Binding - ScrollLeft, - ScrollRight key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Left: key.NewBinding( - key.WithKeys("left", "h"), - key.WithHelp("←", "previous"), - ), - Right: key.NewBinding( - key.WithKeys("right", "l"), - key.WithHelp("→", "next"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "switch"), - ), - Allow: key.NewBinding( - key.WithKeys("a", "A", "ctrl+a"), - key.WithHelp("a", "allow"), - ), - AllowSession: key.NewBinding( - key.WithKeys("s", "S", "ctrl+s"), - key.WithHelp("s", "allow session"), - ), - Deny: key.NewBinding( - key.WithKeys("d", "D", "esc"), - key.WithHelp("d", "deny"), - ), - Select: key.NewBinding( - key.WithKeys("enter", "ctrl+y"), - key.WithHelp("enter", "confirm"), - ), - ToggleDiffMode: key.NewBinding( - key.WithKeys("t"), - key.WithHelp("t", "toggle diff mode"), - ), - ScrollDown: key.NewBinding( - key.WithKeys("shift+down", "J"), - key.WithHelp("shift+↓", "scroll down"), - ), - ScrollUp: key.NewBinding( - key.WithKeys("shift+up", "K"), - key.WithHelp("shift+↑", "scroll up"), - ), - ScrollLeft: key.NewBinding( - key.WithKeys("shift+left", "H"), - key.WithHelp("shift+←", "scroll left"), - ), - ScrollRight: key.NewBinding( - key.WithKeys("shift+right", "L"), - key.WithHelp("shift+→", "scroll right"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Left, - k.Right, - k.Tab, - k.Select, - k.Allow, - k.AllowSession, - k.Deny, - k.ToggleDiffMode, - k.ScrollDown, - k.ScrollUp, - k.ScrollLeft, - k.ScrollRight, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.ToggleDiffMode, - key.NewBinding( - key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"), - key.WithHelp("shift+←↓↑→", "scroll"), - ), - } -} diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go deleted file mode 100644 index d743d36f5b4674cd09fe7761c005fc2b9979252b..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ /dev/null @@ -1,899 +0,0 @@ -package permissions - -import ( - "encoding/json" - "fmt" - "strings" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/viewport" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/agent/tools" - "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" -) - -type PermissionAction string - -// Permission responses -const ( - PermissionAllow PermissionAction = "allow" - PermissionAllowForSession PermissionAction = "allow_session" - PermissionDeny PermissionAction = "deny" - - PermissionsDialogID dialogs.DialogID = "permissions" -) - -// PermissionResponseMsg represents the user's response to a permission request -type PermissionResponseMsg struct { - Permission permission.PermissionRequest - Action PermissionAction -} - -// PermissionDialogCmp interface for permission dialog component -type PermissionDialogCmp interface { - dialogs.DialogModel -} - -// permissionDialogCmp is the implementation of PermissionDialog -type permissionDialogCmp struct { - wWidth int - wHeight int - width int - height int - permission permission.PermissionRequest - contentViewPort viewport.Model - selectedOption int // 0: Allow, 1: Allow for session, 2: Deny - - // Diff view state - defaultDiffSplitMode bool // true for split, false for unified - diffSplitMode *bool // nil means use defaultDiffSplitMode - diffXOffset int // horizontal scroll offset - diffYOffset int // vertical scroll offset - - // Caching - cachedContent string - contentDirty bool - - positionRow int // Row position for dialog - positionCol int // Column position for dialog - - finalDialogHeight int - - keyMap KeyMap -} - -func NewPermissionDialogCmp(permission permission.PermissionRequest, opts *Options) PermissionDialogCmp { - if opts == nil { - opts = &Options{} - } - - // Create viewport for content - contentViewport := viewport.New() - return &permissionDialogCmp{ - contentViewPort: contentViewport, - selectedOption: 0, // Default to "Allow" - permission: permission, - diffSplitMode: opts.isSplitMode(), - keyMap: DefaultKeyMap(), - contentDirty: true, // Mark as dirty initially - } -} - -func (p *permissionDialogCmp) Init() tea.Cmd { - return p.contentViewPort.Init() -} - -func (p *permissionDialogCmp) supportsDiffView() bool { - return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName || p.permission.ToolName == tools.MultiEditToolName -} - -func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - p.wWidth = msg.Width - p.wHeight = msg.Height - p.contentDirty = true // Mark content as dirty on window resize - cmd := p.SetSize() - cmds = append(cmds, cmd) - case tea.KeyPressMsg: - switch { - case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab): - p.selectedOption = (p.selectedOption + 1) % 3 - return p, nil - case key.Matches(msg, p.keyMap.Left): - p.selectedOption = (p.selectedOption + 2) % 3 - case key.Matches(msg, p.keyMap.Select): - return p, p.selectCurrentOption() - case key.Matches(msg, p.keyMap.Allow): - return p, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}), - ) - case key.Matches(msg, p.keyMap.AllowSession): - return p, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}), - ) - case key.Matches(msg, p.keyMap.Deny): - return p, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}), - ) - case key.Matches(msg, p.keyMap.ToggleDiffMode): - if p.supportsDiffView() { - if p.diffSplitMode == nil { - diffSplitMode := !p.defaultDiffSplitMode - p.diffSplitMode = &diffSplitMode - } else { - *p.diffSplitMode = !*p.diffSplitMode - } - p.contentDirty = true // Mark content as dirty when diff mode changes - return p, nil - } - case key.Matches(msg, p.keyMap.ScrollDown): - if p.supportsDiffView() { - p.scrollDown() - return p, nil - } - case key.Matches(msg, p.keyMap.ScrollUp): - if p.supportsDiffView() { - p.scrollUp() - return p, nil - } - case key.Matches(msg, p.keyMap.ScrollLeft): - if p.supportsDiffView() { - p.scrollLeft() - return p, nil - } - case key.Matches(msg, p.keyMap.ScrollRight): - if p.supportsDiffView() { - p.scrollRight() - return p, nil - } - default: - // Pass other keys to viewport - viewPort, cmd := p.contentViewPort.Update(msg) - p.contentViewPort = viewPort - cmds = append(cmds, cmd) - } - case tea.MouseWheelMsg: - if p.supportsDiffView() && p.isMouseOverDialog(msg.Mouse().X, msg.Mouse().Y) { - switch msg.Button { - case tea.MouseWheelDown: - p.scrollDown() - case tea.MouseWheelUp: - p.scrollUp() - case tea.MouseWheelLeft: - p.scrollLeft() - case tea.MouseWheelRight: - p.scrollRight() - } - } - } - - return p, tea.Batch(cmds...) -} - -func (p *permissionDialogCmp) scrollDown() { - p.diffYOffset += 1 - p.contentDirty = true -} - -func (p *permissionDialogCmp) scrollUp() { - p.diffYOffset = max(0, p.diffYOffset-1) - p.contentDirty = true -} - -func (p *permissionDialogCmp) scrollLeft() { - p.diffXOffset = max(0, p.diffXOffset-5) - p.contentDirty = true -} - -func (p *permissionDialogCmp) scrollRight() { - p.diffXOffset += 5 - p.contentDirty = true -} - -// isMouseOverDialog checks if the given mouse coordinates are within the dialog bounds. -// Returns true if the mouse is over the dialog area, false otherwise. -func (p *permissionDialogCmp) isMouseOverDialog(x, y int) bool { - if p.permission.ID == "" { - return false - } - var ( - dialogX = p.positionCol - dialogY = p.positionRow - dialogWidth = p.width - dialogHeight = p.finalDialogHeight - ) - return x >= dialogX && x < dialogX+dialogWidth && y >= dialogY && y < dialogY+dialogHeight -} - -func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd { - var action PermissionAction - - switch p.selectedOption { - case 0: - action = PermissionAllow - case 1: - action = PermissionAllowForSession - case 2: - action = PermissionDeny - } - - return tea.Batch( - util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}), - util.CmdHandler(dialogs.CloseDialogMsg{}), - ) -} - -func (p *permissionDialogCmp) renderButtons() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - - buttons := []core.ButtonOpts{ - { - Text: "Allow", - UnderlineIndex: 0, // "A" - Selected: p.selectedOption == 0, - }, - { - Text: "Allow for Session", - UnderlineIndex: 10, // "S" in "Session" - Selected: p.selectedOption == 1, - }, - { - Text: "Deny", - UnderlineIndex: 0, // "D" - Selected: p.selectedOption == 2, - }, - } - - content := core.SelectableButtons(buttons, " ") - if lipgloss.Width(content) > p.width-4 { - content = core.SelectableButtonsVertical(buttons, 1) - return baseStyle.AlignVertical(lipgloss.Center). - AlignHorizontal(lipgloss.Center). - Width(p.width - 4). - Render(content) - } - - return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content) -} - -func (p *permissionDialogCmp) renderHeader() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - - toolKey := t.S().Muted.Render("Tool") - toolValue := t.S().Text. - Width(p.width - lipgloss.Width(toolKey)). - Render(fmt.Sprintf(" %s", p.permission.ToolName)) - - pathKey := t.S().Muted.Render("Path") - pathValue := t.S().Text. - Width(p.width - lipgloss.Width(pathKey)). - Render(fmt.Sprintf(" %s", fsext.PrettyPath(p.permission.Path))) - - headerParts := []string{ - lipgloss.JoinHorizontal( - lipgloss.Left, - toolKey, - toolValue, - ), - lipgloss.JoinHorizontal( - lipgloss.Left, - pathKey, - pathValue, - ), - } - - // Add tool-specific header information - switch p.permission.ToolName { - case tools.BashToolName: - params := p.permission.Params.(tools.BashPermissionsParams) - descKey := t.S().Muted.Render("Desc") - descValue := t.S().Text. - Width(p.width - lipgloss.Width(descKey)). - Render(fmt.Sprintf(" %s", params.Description)) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - descKey, - descValue, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - t.S().Muted.Width(p.width).Render("Command"), - ) - case tools.DownloadToolName: - params := p.permission.Params.(tools.DownloadPermissionsParams) - urlKey := t.S().Muted.Render("URL") - urlValue := t.S().Text. - Width(p.width - lipgloss.Width(urlKey)). - Render(fmt.Sprintf(" %s", params.URL)) - fileKey := t.S().Muted.Render("File") - filePath := t.S().Text. - Width(p.width - lipgloss.Width(fileKey)). - Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath))) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - urlKey, - urlValue, - ), - lipgloss.JoinHorizontal( - lipgloss.Left, - fileKey, - filePath, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - ) - case tools.EditToolName: - params := p.permission.Params.(tools.EditPermissionsParams) - fileKey := t.S().Muted.Render("File") - filePath := t.S().Text. - Width(p.width - lipgloss.Width(fileKey)). - Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath))) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - fileKey, - filePath, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - ) - - case tools.WriteToolName: - params := p.permission.Params.(tools.WritePermissionsParams) - fileKey := t.S().Muted.Render("File") - filePath := t.S().Text. - Width(p.width - lipgloss.Width(fileKey)). - Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath))) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - fileKey, - filePath, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - ) - case tools.MultiEditToolName: - params := p.permission.Params.(tools.MultiEditPermissionsParams) - fileKey := t.S().Muted.Render("File") - filePath := t.S().Text. - Width(p.width - lipgloss.Width(fileKey)). - Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath))) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - fileKey, - filePath, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - ) - case tools.FetchToolName: - headerParts = append(headerParts, - baseStyle.Render(strings.Repeat(" ", p.width)), - t.S().Muted.Width(p.width).Bold(true).Render("URL"), - ) - case tools.AgenticFetchToolName: - headerParts = append(headerParts, - baseStyle.Render(strings.Repeat(" ", p.width)), - t.S().Muted.Width(p.width).Bold(true).Render("Web"), - ) - case tools.ViewToolName: - params := p.permission.Params.(tools.ViewPermissionsParams) - fileKey := t.S().Muted.Render("File") - filePath := t.S().Text. - Width(p.width - lipgloss.Width(fileKey)). - Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath))) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - fileKey, - filePath, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - ) - case tools.LSToolName: - params := p.permission.Params.(tools.LSPermissionsParams) - pathKey := t.S().Muted.Render("Directory") - pathValue := t.S().Text. - Width(p.width - lipgloss.Width(pathKey)). - Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.Path))) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - pathKey, - pathValue, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - ) - } - - return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...)) -} - -func (p *permissionDialogCmp) getOrGenerateContent() string { - // Return cached content if available and not dirty - if !p.contentDirty && p.cachedContent != "" { - return p.cachedContent - } - - // Generate new content - var content string - switch p.permission.ToolName { - case tools.BashToolName: - content = p.generateBashContent() - case tools.DownloadToolName: - content = p.generateDownloadContent() - case tools.EditToolName: - content = p.generateEditContent() - case tools.WriteToolName: - content = p.generateWriteContent() - case tools.MultiEditToolName: - content = p.generateMultiEditContent() - case tools.FetchToolName: - content = p.generateFetchContent() - case tools.AgenticFetchToolName: - content = p.generateAgenticFetchContent() - case tools.ViewToolName: - content = p.generateViewContent() - case tools.LSToolName: - content = p.generateLSContent() - default: - content = p.generateDefaultContent() - } - - // Cache the result - p.cachedContent = content - p.contentDirty = false - - return content -} - -func (p *permissionDialogCmp) generateBashContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base.Background(t.BgSubtle) - if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok { - content := pr.Command - t := styles.CurrentTheme() - content = strings.TrimSpace(content) - lines := strings.Split(content, "\n") - - width := p.width - 4 - var out []string - for _, ln := range lines { - out = append(out, t.S().Muted. - Width(width). - Padding(0, 3). - Foreground(t.FgBase). - Background(t.BgSubtle). - Render(ln)) - } - - // Ensure minimum of 7 lines for command display - minLines := 7 - for len(out) < minLines { - out = append(out, t.S().Muted. - Width(width). - Padding(0, 3). - Foreground(t.FgBase). - Background(t.BgSubtle). - Render("")) - } - - // Use the cache for markdown rendering - renderedContent := strings.Join(out, "\n") - finalContent := baseStyle. - Width(p.contentViewPort.Width()). - Padding(1, 0). - Render(renderedContent) - - return finalContent - } - return "" -} - -func (p *permissionDialogCmp) generateEditContent() string { - if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok { - formatter := core.DiffFormatter(). - Before(fsext.PrettyPath(pr.FilePath), pr.OldContent). - After(fsext.PrettyPath(pr.FilePath), pr.NewContent). - Height(p.contentViewPort.Height()). - Width(p.contentViewPort.Width()). - XOffset(p.diffXOffset). - YOffset(p.diffYOffset) - if p.useDiffSplitMode() { - formatter = formatter.Split() - } else { - formatter = formatter.Unified() - } - - diff := formatter.String() - return diff - } - return "" -} - -func (p *permissionDialogCmp) generateWriteContent() string { - if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok { - // Use the cache for diff rendering - formatter := core.DiffFormatter(). - Before(fsext.PrettyPath(pr.FilePath), pr.OldContent). - After(fsext.PrettyPath(pr.FilePath), pr.NewContent). - Height(p.contentViewPort.Height()). - Width(p.contentViewPort.Width()). - XOffset(p.diffXOffset). - YOffset(p.diffYOffset) - if p.useDiffSplitMode() { - formatter = formatter.Split() - } else { - formatter = formatter.Unified() - } - - diff := formatter.String() - return diff - } - return "" -} - -func (p *permissionDialogCmp) generateDownloadContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base.Background(t.BgSubtle) - if pr, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok { - content := fmt.Sprintf("URL: %s\nFile: %s", pr.URL, fsext.PrettyPath(pr.FilePath)) - if pr.Timeout > 0 { - content += fmt.Sprintf("\nTimeout: %ds", pr.Timeout) - } - - finalContent := baseStyle. - Padding(1, 2). - Width(p.contentViewPort.Width()). - Render(content) - return finalContent - } - return "" -} - -func (p *permissionDialogCmp) generateMultiEditContent() string { - if pr, ok := p.permission.Params.(tools.MultiEditPermissionsParams); ok { - // Use the cache for diff rendering - formatter := core.DiffFormatter(). - Before(fsext.PrettyPath(pr.FilePath), pr.OldContent). - After(fsext.PrettyPath(pr.FilePath), pr.NewContent). - Height(p.contentViewPort.Height()). - Width(p.contentViewPort.Width()). - XOffset(p.diffXOffset). - YOffset(p.diffYOffset) - if p.useDiffSplitMode() { - formatter = formatter.Split() - } else { - formatter = formatter.Unified() - } - - diff := formatter.String() - return diff - } - return "" -} - -func (p *permissionDialogCmp) generateFetchContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base.Background(t.BgSubtle) - if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok { - finalContent := baseStyle. - Padding(1, 2). - Width(p.contentViewPort.Width()). - Render(pr.URL) - return finalContent - } - return "" -} - -func (p *permissionDialogCmp) generateAgenticFetchContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base.Background(t.BgSubtle) - if pr, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams); ok { - var content string - if pr.URL != "" { - content = fmt.Sprintf("URL: %s\n\nPrompt: %s", pr.URL, pr.Prompt) - } else { - content = fmt.Sprintf("Prompt: %s", pr.Prompt) - } - finalContent := baseStyle. - Padding(1, 2). - Width(p.contentViewPort.Width()). - Render(content) - return finalContent - } - return "" -} - -func (p *permissionDialogCmp) generateViewContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base.Background(t.BgSubtle) - if pr, ok := p.permission.Params.(tools.ViewPermissionsParams); ok { - content := fmt.Sprintf("File: %s", fsext.PrettyPath(pr.FilePath)) - if pr.Offset > 0 { - content += fmt.Sprintf("\nStarting from line: %d", pr.Offset+1) - } - if pr.Limit > 0 && pr.Limit != 2000 { // 2000 is the default limit - content += fmt.Sprintf("\nLines to read: %d", pr.Limit) - } - - finalContent := baseStyle. - Padding(1, 2). - Width(p.contentViewPort.Width()). - Render(content) - return finalContent - } - return "" -} - -func (p *permissionDialogCmp) generateLSContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base.Background(t.BgSubtle) - if pr, ok := p.permission.Params.(tools.LSPermissionsParams); ok { - content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(pr.Path)) - if len(pr.Ignore) > 0 { - content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(pr.Ignore, ", ")) - } - - finalContent := baseStyle. - Padding(1, 2). - Width(p.contentViewPort.Width()). - Render(content) - return finalContent - } - return "" -} - -func (p *permissionDialogCmp) generateDefaultContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base.Background(t.BgSubtle) - - content := p.permission.Description - - // Add pretty-printed JSON parameters for MCP tools - if p.permission.Params != nil { - var paramStr string - - // Ensure params is a string - if str, ok := p.permission.Params.(string); ok { - paramStr = str - } else { - paramStr = fmt.Sprintf("%v", p.permission.Params) - } - - // Try to parse as JSON for pretty printing - var parsed any - if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil { - if b, err := json.MarshalIndent(parsed, "", " "); err == nil { - if content != "" { - content += "\n\n" - } - content += string(b) - } - } else { - // Not JSON, show as-is - if content != "" { - content += "\n\n" - } - content += paramStr - } - } - - content = strings.TrimSpace(content) - content = "\n" + content + "\n" - lines := strings.Split(content, "\n") - - width := p.width - 4 - var out []string - for _, ln := range lines { - ln = " " + ln // left padding - if len(ln) > width { - ln = ansi.Truncate(ln, width, "…") - } - out = append(out, t.S().Muted. - Width(width). - Foreground(t.FgBase). - Background(t.BgSubtle). - Render(ln)) - } - - // Use the cache for markdown rendering - renderedContent := strings.Join(out, "\n") - finalContent := baseStyle. - Width(p.contentViewPort.Width()). - Render(renderedContent) - - if renderedContent == "" { - return "" - } - - return finalContent -} - -func (p *permissionDialogCmp) useDiffSplitMode() bool { - if p.diffSplitMode != nil { - return *p.diffSplitMode - } - return p.defaultDiffSplitMode -} - -func (p *permissionDialogCmp) styleViewport() string { - t := styles.CurrentTheme() - return t.S().Base.Render(p.contentViewPort.View()) -} - -func (p *permissionDialogCmp) render() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - title := core.Title("Permission Required", p.width-4) - // Render header - headerContent := p.renderHeader() - // Render buttons - buttons := p.renderButtons() - - p.contentViewPort.SetWidth(p.width - 4) - - // Always set viewport content (the caching is handled in getOrGenerateContent) - const minContentHeight = 9 - - availableDialogHeight := max(minContentHeight, p.height-minContentHeight) - p.contentViewPort.SetHeight(availableDialogHeight) - contentFinal := p.getOrGenerateContent() - contentHeight := min(availableDialogHeight, lipgloss.Height(contentFinal)) - - p.contentViewPort.SetHeight(contentHeight) - p.contentViewPort.SetContent(contentFinal) - - p.positionRow = p.wHeight / 2 - p.positionRow -= (contentHeight + 9) / 2 - p.positionRow -= 3 // Move dialog slightly higher than middle - - var contentHelp string - if p.supportsDiffView() { - contentHelp = help.New().View(p.keyMap) - } - - // Calculate content height dynamically based on window size - strs := []string{ - title, - "", - headerContent, - "", - p.styleViewport(), - "", - buttons, - "", - } - if contentHelp != "" { - strs = append(strs, "", contentHelp) - } - content := lipgloss.JoinVertical(lipgloss.Top, strs...) - - dialog := baseStyle. - Padding(0, 1). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus). - Width(p.width). - Render( - content, - ) - p.finalDialogHeight = lipgloss.Height(dialog) - return dialog -} - -func (p *permissionDialogCmp) View() string { - return p.render() -} - -func (p *permissionDialogCmp) SetSize() tea.Cmd { - if p.permission.ID == "" { - return nil - } - - oldWidth, oldHeight := p.width, p.height - - switch p.permission.ToolName { - case tools.BashToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.3) - case tools.DownloadToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.4) - case tools.EditToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.8) - case tools.WriteToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.8) - case tools.MultiEditToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.8) - case tools.FetchToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.3) - case tools.AgenticFetchToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.4) - case tools.ViewToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.4) - case tools.LSToolName: - p.width = int(float64(p.wWidth) * 0.8) - p.height = int(float64(p.wHeight) * 0.4) - default: - p.width = int(float64(p.wWidth) * 0.7) - p.height = int(float64(p.wHeight) * 0.5) - } - - // Default to diff split mode when dialog is wide enough. - p.defaultDiffSplitMode = p.width >= 140 - - // Set a maximum width for the dialog - p.width = min(p.width, 180) - - // Mark content as dirty if size changed - if oldWidth != p.width || oldHeight != p.height { - p.contentDirty = true - } - p.positionRow = p.wHeight / 2 - p.positionRow -= p.height / 2 - p.positionRow -= 3 // Move dialog slightly higher than middle - p.positionCol = p.wWidth / 2 - p.positionCol -= p.width / 2 - return nil -} - -func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string { - content, err := generator() - if err != nil { - return fmt.Sprintf("Error rendering markdown: %v", err) - } - - return content -} - -// ID implements PermissionDialogCmp. -func (p *permissionDialogCmp) ID() dialogs.DialogID { - return PermissionsDialogID -} - -// Position implements PermissionDialogCmp. -func (p *permissionDialogCmp) Position() (int, int) { - return p.positionRow, p.positionCol -} - -// Options for create a new permission dialog -type Options struct { - DiffMode string // split or unified, empty means use defaultDiffSplitMode -} - -// isSplitMode returns internal representation of diff mode switch -func (o Options) isSplitMode() *bool { - var split bool - - switch o.DiffMode { - case "split": - split = true - case "unified": - split = false - default: - return nil - } - - return &split -} diff --git a/internal/tui/components/dialogs/quit/keys.go b/internal/tui/components/dialogs/quit/keys.go deleted file mode 100644 index 15b3e85e0da960a9a63562427f2f2e2f624ab627..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/quit/keys.go +++ /dev/null @@ -1,75 +0,0 @@ -package quit - -import ( - "charm.land/bubbles/v2/key" -) - -// KeyMap defines the keyboard bindings for the quit dialog. -type KeyMap struct { - LeftRight, - EnterSpace, - Yes, - No, - Tab, - Close key.Binding -} - -func DefaultKeymap() KeyMap { - return KeyMap{ - LeftRight: key.NewBinding( - key.WithKeys("left", "right"), - key.WithHelp("←/→", "switch options"), - ), - EnterSpace: key.NewBinding( - key.WithKeys("enter", " "), - key.WithHelp("enter/space", "confirm"), - ), - Yes: key.NewBinding( - key.WithKeys("y", "Y", "ctrl+c"), - key.WithHelp("y/Y/ctrl+c", "yes"), - ), - No: key.NewBinding( - key.WithKeys("n", "N"), - key.WithHelp("n/N", "no"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "switch options"), - ), - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.LeftRight, - k.EnterSpace, - k.Yes, - k.No, - k.Tab, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.LeftRight, - k.EnterSpace, - } -} diff --git a/internal/tui/components/dialogs/quit/quit.go b/internal/tui/components/dialogs/quit/quit.go deleted file mode 100644 index 4ffc04a0d1bf2397e2c00c7b321c360d9566d623..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/quit/quit.go +++ /dev/null @@ -1,120 +0,0 @@ -package quit - -import ( - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const ( - question = "Are you sure you want to quit?" - QuitDialogID dialogs.DialogID = "quit" -) - -// QuitDialog represents a confirmation dialog for quitting the application. -type QuitDialog interface { - dialogs.DialogModel -} - -type quitDialogCmp struct { - wWidth int - wHeight int - - selectedNo bool // true if "No" button is selected - keymap KeyMap -} - -// NewQuitDialog creates a new quit confirmation dialog. -func NewQuitDialog() QuitDialog { - return &quitDialogCmp{ - selectedNo: true, // Default to "No" for safety - keymap: DefaultKeymap(), - } -} - -func (q *quitDialogCmp) Init() tea.Cmd { - return nil -} - -// Update handles keyboard input for the quit dialog. -func (q *quitDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - q.wWidth = msg.Width - q.wHeight = msg.Height - case tea.KeyPressMsg: - switch { - case key.Matches(msg, q.keymap.LeftRight, q.keymap.Tab): - q.selectedNo = !q.selectedNo - return q, nil - case key.Matches(msg, q.keymap.EnterSpace): - if !q.selectedNo { - return q, tea.Quit - } - return q, util.CmdHandler(dialogs.CloseDialogMsg{}) - case key.Matches(msg, q.keymap.Yes): - return q, tea.Quit - case key.Matches(msg, q.keymap.No, q.keymap.Close): - return q, util.CmdHandler(dialogs.CloseDialogMsg{}) - } - } - return q, nil -} - -// View renders the quit dialog with Yes/No buttons. -func (q *quitDialogCmp) View() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - yesStyle := t.S().Text - noStyle := yesStyle - - if q.selectedNo { - noStyle = noStyle.Foreground(t.White).Background(t.Secondary) - yesStyle = yesStyle.Background(t.BgSubtle) - } else { - yesStyle = yesStyle.Foreground(t.White).Background(t.Secondary) - noStyle = noStyle.Background(t.BgSubtle) - } - - const horizontalPadding = 3 - yesButton := yesStyle.PaddingLeft(horizontalPadding).Underline(true).Render("Y") + - yesStyle.PaddingRight(horizontalPadding).Render("ep!") - noButton := noStyle.PaddingLeft(horizontalPadding).Underline(true).Render("N") + - noStyle.PaddingRight(horizontalPadding).Render("ope") - - buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render( - lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton), - ) - - content := baseStyle.Render( - lipgloss.JoinVertical( - lipgloss.Center, - question, - "", - buttons, - ), - ) - - quitDialogStyle := baseStyle. - Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) - - return quitDialogStyle.Render(content) -} - -func (q *quitDialogCmp) Position() (int, int) { - row := q.wHeight / 2 - row -= 7 / 2 - col := q.wWidth / 2 - col -= (lipgloss.Width(question) + 4) / 2 - - return row, col -} - -func (q *quitDialogCmp) ID() dialogs.DialogID { - return QuitDialogID -} diff --git a/internal/tui/components/dialogs/reasoning/reasoning.go b/internal/tui/components/dialogs/reasoning/reasoning.go deleted file mode 100644 index dfe6898b90b516903dc3b6b490641899c8cc6ca2..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/reasoning/reasoning.go +++ /dev/null @@ -1,264 +0,0 @@ -package reasoning - -import ( - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "golang.org/x/text/cases" - "golang.org/x/text/language" - - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const ( - ReasoningDialogID dialogs.DialogID = "reasoning" - - defaultWidth int = 50 -) - -type listModel = list.FilterableList[list.CompletionItem[EffortOption]] - -type EffortOption struct { - Title string - Effort string -} - -type ReasoningDialog interface { - dialogs.DialogModel -} - -type reasoningDialogCmp struct { - width int - wWidth int // Width of the terminal window - wHeight int // Height of the terminal window - - effortList listModel - keyMap ReasoningDialogKeyMap - help help.Model -} - -type ReasoningEffortSelectedMsg struct { - Effort string -} - -type ReasoningDialogKeyMap struct { - Next key.Binding - Previous key.Binding - Select key.Binding - Close key.Binding -} - -func DefaultReasoningDialogKeyMap() ReasoningDialogKeyMap { - return ReasoningDialogKeyMap{ - Next: key.NewBinding( - key.WithKeys("down", "j", "ctrl+n"), - key.WithHelp("↓/j/ctrl+n", "next"), - ), - Previous: key.NewBinding( - key.WithKeys("up", "k", "ctrl+p"), - key.WithHelp("↑/k/ctrl+p", "previous"), - ), - Select: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select"), - ), - Close: key.NewBinding( - key.WithKeys("esc", "ctrl+c"), - key.WithHelp("esc/ctrl+c", "close"), - ), - } -} - -func (k ReasoningDialogKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Select, k.Close} -} - -func (k ReasoningDialogKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.Next, k.Previous}, - {k.Select, k.Close}, - } -} - -func NewReasoningDialog() ReasoningDialog { - keyMap := DefaultReasoningDialogKeyMap() - listKeyMap := list.DefaultKeyMap() - listKeyMap.Down.SetEnabled(false) - listKeyMap.Up.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next - listKeyMap.UpOneItem = keyMap.Previous - - t := styles.CurrentTheme() - inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) - effortList := list.NewFilterableList( - []list.CompletionItem[EffortOption]{}, - list.WithFilterInputStyle(inputStyle), - list.WithFilterListOptions( - list.WithKeyMap(listKeyMap), - list.WithWrapNavigation(), - list.WithResizeByList(), - ), - ) - help := help.New() - help.Styles = t.S().Help - - return &reasoningDialogCmp{ - effortList: effortList, - width: defaultWidth, - keyMap: keyMap, - help: help, - } -} - -func (r *reasoningDialogCmp) Init() tea.Cmd { - return r.populateEffortOptions() -} - -func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd { - cfg := config.Get() - if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok { - selectedModel := cfg.Models[agentCfg.Model] - model := cfg.GetModelByType(agentCfg.Model) - - // Get current reasoning effort - currentEffort := selectedModel.ReasoningEffort - if currentEffort == "" && model != nil { - currentEffort = model.DefaultReasoningEffort - } - - efforts := []EffortOption{} - caser := cases.Title(language.Und) - for _, level := range model.ReasoningLevels { - efforts = append(efforts, EffortOption{ - Title: caser.String(level), - Effort: level, - }) - } - - effortItems := []list.CompletionItem[EffortOption]{} - selectedID := "" - for _, effort := range efforts { - opts := []list.CompletionItemOption{ - list.WithCompletionID(effort.Effort), - } - if effort.Effort == currentEffort { - opts = append(opts, list.WithCompletionShortcut("current")) - selectedID = effort.Effort - } - effortItems = append(effortItems, list.NewCompletionItem( - effort.Title, - effort, - opts..., - )) - } - - cmd := r.effortList.SetItems(effortItems) - // Set the current effort as the selected item - if currentEffort != "" && selectedID != "" { - return tea.Sequence(cmd, r.effortList.SetSelected(selectedID)) - } - return cmd - } - return nil -} - -func (r *reasoningDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - r.wWidth = msg.Width - r.wHeight = msg.Height - return r, r.effortList.SetSize(r.listWidth(), r.listHeight()) - case tea.KeyPressMsg: - switch { - case key.Matches(msg, r.keyMap.Select): - selectedItem := r.effortList.SelectedItem() - if selectedItem == nil { - return r, nil // No item selected, do nothing - } - effort := (*selectedItem).Value() - return r, tea.Sequence( - util.CmdHandler(dialogs.CloseDialogMsg{}), - func() tea.Msg { - return ReasoningEffortSelectedMsg{ - Effort: effort.Effort, - } - }, - ) - case key.Matches(msg, r.keyMap.Close): - return r, util.CmdHandler(dialogs.CloseDialogMsg{}) - default: - u, cmd := r.effortList.Update(msg) - r.effortList = u.(listModel) - return r, cmd - } - } - return r, nil -} - -func (r *reasoningDialogCmp) View() string { - t := styles.CurrentTheme() - listView := r.effortList - - header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Select Reasoning Effort", r.width-4)) - content := lipgloss.JoinVertical( - lipgloss.Left, - header, - listView.View(), - "", - t.S().Base.Width(r.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(r.help.View(r.keyMap)), - ) - return r.style().Render(content) -} - -func (r *reasoningDialogCmp) Cursor() *tea.Cursor { - if cursor, ok := r.effortList.(util.Cursor); ok { - cursor := cursor.Cursor() - if cursor != nil { - cursor = r.moveCursor(cursor) - } - return cursor - } - return nil -} - -func (r *reasoningDialogCmp) listWidth() int { - return r.width - 2 // 4 for padding -} - -func (r *reasoningDialogCmp) listHeight() int { - listHeight := len(r.effortList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections - return min(listHeight, r.wHeight/2) -} - -func (r *reasoningDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - row, col := r.Position() - offset := row + 3 - cursor.Y += offset - cursor.X = cursor.X + col + 2 - return cursor -} - -func (r *reasoningDialogCmp) style() lipgloss.Style { - t := styles.CurrentTheme() - return t.S().Base. - Width(r.width). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) -} - -func (r *reasoningDialogCmp) Position() (int, int) { - row := r.wHeight/4 - 2 // just a bit above the center - col := r.wWidth / 2 - col -= r.width / 2 - return row, col -} - -func (r *reasoningDialogCmp) ID() dialogs.DialogID { - return ReasoningDialogID -} diff --git a/internal/tui/components/dialogs/sessions/keys.go b/internal/tui/components/dialogs/sessions/keys.go deleted file mode 100644 index 94b260bd71261699413151836c672b2498e03abe..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/sessions/keys.go +++ /dev/null @@ -1,67 +0,0 @@ -package sessions - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - Select, - Next, - Previous, - Close key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Select: key.NewBinding( - key.WithKeys("enter", "tab", "ctrl+y"), - key.WithHelp("enter", "choose"), - ), - Next: key.NewBinding( - key.WithKeys("down", "ctrl+n"), - key.WithHelp("↓", "next item"), - ), - Previous: key.NewBinding( - key.WithKeys("up", "ctrl+p"), - key.WithHelp("↑", "previous item"), - ), - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "exit"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Select, - k.Next, - k.Previous, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - key.NewBinding( - - key.WithKeys("down", "up"), - key.WithHelp("↑↓", "choose"), - ), - k.Select, - k.Close, - } -} diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go deleted file mode 100644 index 11515eeedf8347b8eba5c94b7e0d35715d1380cc..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/sessions/sessions.go +++ /dev/null @@ -1,181 +0,0 @@ -package sessions - -import ( - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/event" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/exp/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const SessionsDialogID dialogs.DialogID = "sessions" - -// SessionDialog interface for the session switching dialog -type SessionDialog interface { - dialogs.DialogModel -} - -type SessionsList = list.FilterableList[list.CompletionItem[session.Session]] - -type sessionDialogCmp struct { - selectedInx int - wWidth int - wHeight int - width int - selectedSessionID string - keyMap KeyMap - sessionsList SessionsList - help help.Model -} - -// NewSessionDialogCmp creates a new session switching dialog -func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionDialog { - t := styles.CurrentTheme() - listKeyMap := list.DefaultKeyMap() - keyMap := DefaultKeyMap() - listKeyMap.Down.SetEnabled(false) - listKeyMap.Up.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next - listKeyMap.UpOneItem = keyMap.Previous - - items := make([]list.CompletionItem[session.Session], len(sessions)) - if len(sessions) > 0 { - for i, session := range sessions { - items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID)) - } - } - - inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) - sessionsList := list.NewFilterableList( - items, - list.WithFilterPlaceholder("Enter a session name"), - list.WithFilterInputStyle(inputStyle), - list.WithFilterListOptions( - list.WithKeyMap(listKeyMap), - list.WithWrapNavigation(), - ), - ) - help := help.New() - help.Styles = t.S().Help - s := &sessionDialogCmp{ - selectedSessionID: selectedID, - keyMap: DefaultKeyMap(), - sessionsList: sessionsList, - help: help, - } - - return s -} - -func (s *sessionDialogCmp) Init() tea.Cmd { - var cmds []tea.Cmd - cmds = append(cmds, s.sessionsList.Init()) - cmds = append(cmds, s.sessionsList.Focus()) - return tea.Sequence(cmds...) -} - -func (s *sessionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - var cmds []tea.Cmd - s.wWidth = msg.Width - s.wHeight = msg.Height - s.width = min(120, s.wWidth-8) - s.sessionsList.SetInputWidth(s.listWidth() - 2) - cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight())) - if s.selectedSessionID != "" { - cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID)) - } - return s, tea.Batch(cmds...) - case tea.KeyPressMsg: - switch { - case key.Matches(msg, s.keyMap.Select): - selectedItem := s.sessionsList.SelectedItem() - if selectedItem != nil { - selected := *selectedItem - event.SessionSwitched() - return s, tea.Sequence( - util.CmdHandler(dialogs.CloseDialogMsg{}), - util.CmdHandler( - chat.SessionSelectedMsg(selected.Value()), - ), - ) - } - case key.Matches(msg, s.keyMap.Close): - return s, util.CmdHandler(dialogs.CloseDialogMsg{}) - default: - u, cmd := s.sessionsList.Update(msg) - s.sessionsList = u.(SessionsList) - return s, cmd - } - } - return s, nil -} - -func (s *sessionDialogCmp) View() string { - t := styles.CurrentTheme() - listView := s.sessionsList.View() - content := lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Session", s.width-4)), - listView, - "", - t.S().Base.Width(s.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(s.help.View(s.keyMap)), - ) - - return s.style().Render(content) -} - -func (s *sessionDialogCmp) Cursor() *tea.Cursor { - if cursor, ok := s.sessionsList.(util.Cursor); ok { - cursor := cursor.Cursor() - if cursor != nil { - cursor = s.moveCursor(cursor) - } - return cursor - } - return nil -} - -func (s *sessionDialogCmp) style() lipgloss.Style { - t := styles.CurrentTheme() - return t.S().Base. - Width(s.width). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) -} - -func (s *sessionDialogCmp) listHeight() int { - return s.wHeight/2 - 6 // 5 for the border, title and help -} - -func (s *sessionDialogCmp) listWidth() int { - return s.width - 2 // 2 for the border -} - -func (s *sessionDialogCmp) Position() (int, int) { - row := s.wHeight/4 - 2 // just a bit above the center - col := s.wWidth / 2 - col -= s.width / 2 - return row, col -} - -func (s *sessionDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - row, col := s.Position() - offset := row + 3 // Border + title - cursor.Y += offset - cursor.X = cursor.X + col + 2 - return cursor -} - -// ID implements SessionDialog. -func (s *sessionDialogCmp) ID() dialogs.DialogID { - return SessionsDialogID -} diff --git a/internal/tui/components/files/files.go b/internal/tui/components/files/files.go deleted file mode 100644 index c7898d472452fd8465394ccea1131a15224712b2..0000000000000000000000000000000000000000 --- a/internal/tui/components/files/files.go +++ /dev/null @@ -1,146 +0,0 @@ -package files - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/crush/internal/history" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/styles" -) - -// FileHistory represents a file history with initial and latest versions. -type FileHistory struct { - InitialVersion history.File - LatestVersion history.File -} - -// SessionFile represents a file with its history information. -type SessionFile struct { - History FileHistory - FilePath string - Additions int - Deletions int -} - -// RenderOptions contains options for rendering file lists. -type RenderOptions struct { - MaxWidth int - MaxItems int - ShowSection bool - SectionName string -} - -// RenderFileList renders a list of file status items with the given options. -func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string { - t := styles.CurrentTheme() - fileList := []string{} - - if opts.ShowSection { - sectionName := opts.SectionName - if sectionName == "" { - sectionName = "Modified Files" - } - section := t.S().Subtle.Render(sectionName) - fileList = append(fileList, section, "") - } - - if len(fileSlice) == 0 { - fileList = append(fileList, t.S().Base.Foreground(t.Border).Render("None")) - return fileList - } - - // Sort files by the latest version's created time - sort.Slice(fileSlice, func(i, j int) bool { - if fileSlice[i].History.LatestVersion.CreatedAt == fileSlice[j].History.LatestVersion.CreatedAt { - return strings.Compare(fileSlice[i].FilePath, fileSlice[j].FilePath) < 0 - } - return fileSlice[i].History.LatestVersion.CreatedAt > fileSlice[j].History.LatestVersion.CreatedAt - }) - - // Determine how many items to show - maxItems := len(fileSlice) - if opts.MaxItems > 0 { - maxItems = min(opts.MaxItems, len(fileSlice)) - } - - filesShown := 0 - for _, file := range fileSlice { - if file.Additions == 0 && file.Deletions == 0 { - continue // skip files with no changes - } - if filesShown >= maxItems { - break - } - - var statusParts []string - if file.Additions > 0 { - statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions))) - } - if file.Deletions > 0 { - statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions))) - } - - extraContent := strings.Join(statusParts, " ") - cwd := config.Get().WorkingDir() + string(os.PathSeparator) - filePath := file.FilePath - if rel, err := filepath.Rel(cwd, filePath); err == nil { - filePath = rel - } - filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2) - filePath = ansi.Truncate(filePath, opts.MaxWidth-lipgloss.Width(extraContent)-2, "…") - - fileList = append(fileList, - core.Status( - core.StatusOpts{ - Title: filePath, - ExtraContent: extraContent, - }, - opts.MaxWidth, - ), - ) - filesShown++ - } - - return fileList -} - -// RenderFileBlock renders a complete file block with optional truncation indicator. -func RenderFileBlock(fileSlice []SessionFile, opts RenderOptions, showTruncationIndicator bool) string { - t := styles.CurrentTheme() - fileList := RenderFileList(fileSlice, opts) - - // Add truncation indicator if needed - if showTruncationIndicator && opts.MaxItems > 0 { - totalFilesWithChanges := 0 - for _, file := range fileSlice { - if file.Additions > 0 || file.Deletions > 0 { - totalFilesWithChanges++ - } - } - if totalFilesWithChanges > opts.MaxItems { - remaining := totalFilesWithChanges - opts.MaxItems - if remaining == 1 { - fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…")) - } else { - fileList = append(fileList, - t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), - ) - } - } - } - - content := lipgloss.JoinVertical(lipgloss.Left, fileList...) - if opts.MaxWidth > 0 { - return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content) - } - return content -} diff --git a/internal/tui/components/image/image.go b/internal/tui/components/image/image.go deleted file mode 100644 index b526b1bb0a4b1eaf186a55475980bd81f5704ff6..0000000000000000000000000000000000000000 --- a/internal/tui/components/image/image.go +++ /dev/null @@ -1,86 +0,0 @@ -// Based on the implementation by @trashhalo at: -// https://github.com/trashhalo/imgcat -package image - -import ( - "fmt" - _ "image/jpeg" - _ "image/png" - - tea "charm.land/bubbletea/v2" -) - -type Model struct { - url string - image string - width uint - height uint - err error -} - -func New(width, height uint, url string) Model { - return Model{ - width: width, - height: height, - url: url, - } -} - -func (m Model) Init() tea.Cmd { - return nil -} - -func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { - switch msg := msg.(type) { - case errMsg: - m.err = msg - return m, nil - case redrawMsg: - m.width = msg.width - m.height = msg.height - m.url = msg.url - return m, loadURL(m.url) - case loadMsg: - return handleLoadMsg(m, msg) - } - return m, nil -} - -func (m Model) View() string { - if m.err != nil { - return fmt.Sprintf("couldn't load image(s): %v", m.err) - } - return m.image -} - -type errMsg struct{ error } - -func (m Model) Redraw(width uint, height uint, url string) tea.Cmd { - return func() tea.Msg { - return redrawMsg{ - width: width, - height: height, - url: url, - } - } -} - -func (m Model) UpdateURL(url string) tea.Cmd { - return func() tea.Msg { - return redrawMsg{ - width: m.width, - height: m.height, - url: url, - } - } -} - -type redrawMsg struct { - width uint - height uint - url string -} - -func (m Model) IsLoading() bool { - return m.image == "" -} diff --git a/internal/tui/components/image/load.go b/internal/tui/components/image/load.go deleted file mode 100644 index 2ca5d4bac77bdd660faf5bd41bdb1e385b4610a0..0000000000000000000000000000000000000000 --- a/internal/tui/components/image/load.go +++ /dev/null @@ -1,169 +0,0 @@ -// Based on the implementation by @trashhalo at: -// https://github.com/trashhalo/imgcat -package image - -import ( - "bytes" - "context" - "encoding/base64" - "image" - "image/png" - "io" - "net/http" - "os" - "strings" - - tea "charm.land/bubbletea/v2" - "github.com/disintegration/imageorient" - "github.com/lucasb-eyer/go-colorful" - "github.com/muesli/termenv" - "github.com/nfnt/resize" - "github.com/srwiley/oksvg" - "github.com/srwiley/rasterx" -) - -type loadMsg struct { - io.ReadCloser -} - -func loadURL(url string) tea.Cmd { - var r io.ReadCloser - var err error - - if strings.HasPrefix(url, "http") { - var resp *http.Request - resp, err = http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) - r = resp.Body - } else { - r, err = os.Open(url) - } - - if err != nil { - return func() tea.Msg { - return errMsg{err} - } - } - - return load(r) -} - -func load(r io.ReadCloser) tea.Cmd { - return func() tea.Msg { - return loadMsg{r} - } -} - -func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) { - defer msg.Close() - - img, err := readerToImage(m.width, m.height, m.url, msg) - if err != nil { - return m, func() tea.Msg { return errMsg{err} } - } - m.image = img - return m, nil -} - -func imageToString(width, height uint, img image.Image) (string, error) { - img = resize.Thumbnail(width, height*2-4, img, resize.Lanczos3) - b := img.Bounds() - w := b.Max.X - h := b.Max.Y - p := termenv.ColorProfile() - str := strings.Builder{} - for y := 0; y < h; y += 2 { - for x := w; x < int(width); x = x + 2 { - str.WriteString(" ") - } - for x := range w { - c1, _ := colorful.MakeColor(img.At(x, y)) - color1 := p.Color(c1.Hex()) - c2, _ := colorful.MakeColor(img.At(x, y+1)) - color2 := p.Color(c2.Hex()) - str.WriteString(termenv.String("▀"). - Foreground(color1). - Background(color2). - String()) - } - str.WriteString("\n") - } - return str.String(), nil -} - -func readerToImage(width uint, height uint, url string, r io.Reader) (string, error) { - if strings.HasSuffix(strings.ToLower(url), ".svg") { - return svgToImage(width, height, r) - } - - img, _, err := imageorient.Decode(r) - if err != nil { - return "", err - } - - return imageToString(width, height, img) -} - -func svgToImage(width uint, height uint, r io.Reader) (string, error) { - // Original author: https://stackoverflow.com/users/10826783/usual-human - // https://stackoverflow.com/questions/42993407/how-to-create-and-export-svg-to-png-jpeg-in-golang - // Adapted to use size from SVG, and to use temp file. - - tmpPngFile, err := os.CreateTemp("", "img.*.png") - if err != nil { - return "", err - } - tmpPngPath := tmpPngFile.Name() - defer os.Remove(tmpPngPath) - defer tmpPngFile.Close() - - // Rasterize the SVG: - icon, err := oksvg.ReadIconStream(r) - if err != nil { - return "", err - } - w := int(icon.ViewBox.W) - h := int(icon.ViewBox.H) - icon.SetTarget(0, 0, float64(w), float64(h)) - rgba := image.NewRGBA(image.Rect(0, 0, w, h)) - icon.Draw(rasterx.NewDasher(w, h, rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())), 1) - // Write rasterized image as PNG: - err = png.Encode(tmpPngFile, rgba) - if err != nil { - tmpPngFile.Close() - return "", err - } - tmpPngFile.Close() - - rPng, err := os.Open(tmpPngPath) - if err != nil { - return "", err - } - defer rPng.Close() - - img, _, err := imageorient.Decode(rPng) - if err != nil { - return "", err - } - return imageToString(width, height, img) -} - -// ImageFromBase64 renders an image from base64-encoded data. -func ImageFromBase64(width, height uint, data, mediaType string) (string, error) { - decoded, err := base64.StdEncoding.DecodeString(data) - if err != nil { - return "", err - } - - r := bytes.NewReader(decoded) - - if strings.Contains(mediaType, "svg") { - return svgToImage(width, height, r) - } - - img, _, err := imageorient.Decode(r) - if err != nil { - return "", err - } - - return imageToString(width, height, img) -} diff --git a/internal/tui/components/logo/logo.go b/internal/tui/components/logo/logo.go deleted file mode 100644 index 9f4cdfef36723cc69dd13f4a60dcd76f0c8f9904..0000000000000000000000000000000000000000 --- a/internal/tui/components/logo/logo.go +++ /dev/null @@ -1,346 +0,0 @@ -// Package logo renders a Crush wordmark in a stylized way. -package logo - -import ( - "fmt" - "image/color" - "strings" - - "charm.land/lipgloss/v2" - "github.com/MakeNowJust/heredoc" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/exp/slice" -) - -// letterform represents a letterform. It can be stretched horizontally by -// a given amount via the boolean argument. -type letterform func(bool) string - -const diag = `╱` - -// Opts are the options for rendering the Crush title art. -type Opts struct { - FieldColor color.Color // diagonal lines - TitleColorA color.Color // left gradient ramp point - TitleColorB color.Color // right gradient ramp point - CharmColor color.Color // Charm™ text color - VersionColor color.Color // Version text color - Width int // width of the rendered logo, used for truncation -} - -// Render renders the Crush logo. Set the argument to true to render the narrow -// version, intended for use in a sidebar. -// -// The compact argument determines whether it renders compact for the sidebar -// or wider for the main pane. -func Render(version string, compact bool, o Opts) string { - const charm = " Charm™" - - fg := func(c color.Color, s string) string { - return lipgloss.NewStyle().Foreground(c).Render(s) - } - - // Title. - const spacing = 1 - letterforms := []letterform{ - letterC, - letterR, - letterU, - letterSStylized, - letterH, - } - stretchIndex := -1 // -1 means no stretching. - if !compact { - stretchIndex = cachedRandN(len(letterforms)) - } - - crush := renderWord(spacing, stretchIndex, letterforms...) - crushWidth := lipgloss.Width(crush) - b := new(strings.Builder) - for r := range strings.SplitSeq(crush, "\n") { - fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB)) - } - crush = b.String() - - // Charm and version. - metaRowGap := 1 - maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap - version = ansi.Truncate(version, maxVersionWidth, "…") // truncate version if too long. - gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version)) - metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version) - - // Join the meta row and big Crush title. - crush = strings.TrimSpace(metaRow + "\n" + crush) - - // Narrow version. - if compact { - field := fg(o.FieldColor, strings.Repeat(diag, crushWidth)) - return strings.Join([]string{field, field, crush, field, ""}, "\n") - } - - fieldHeight := lipgloss.Height(crush) - - // Left field. - const leftWidth = 6 - leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth)) - leftField := new(strings.Builder) - for range fieldHeight { - fmt.Fprintln(leftField, leftFieldRow) - } - - // Right field. - rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap. - const stepDownAt = 0 - rightField := new(strings.Builder) - for i := range fieldHeight { - width := rightWidth - if i >= stepDownAt { - width = rightWidth - (i - stepDownAt) - } - fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n") - } - - // Return the wide version. - const hGap = " " - logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String()) - if o.Width > 0 { - // Truncate the logo to the specified width. - lines := strings.Split(logo, "\n") - for i, line := range lines { - lines[i] = ansi.Truncate(line, o.Width, "") - } - logo = strings.Join(lines, "\n") - } - return logo -} - -// SmallRender renders a smaller version of the Crush logo, suitable for -// smaller windows or sidebar usage. -func SmallRender(width int) string { - t := styles.CurrentTheme() - title := t.S().Base.Foreground(t.Secondary).Render("Charm™") - title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary)) - remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush" - if remainingWidth > 0 { - lines := strings.Repeat("╱", remainingWidth) - title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines)) - } - return title -} - -// renderWord renders letterforms to fork a word. stretchIndex is the index of -// the letter to stretch, or -1 if no letter should be stretched. -func renderWord(spacing int, stretchIndex int, letterforms ...letterform) string { - if spacing < 0 { - spacing = 0 - } - - renderedLetterforms := make([]string, len(letterforms)) - - // pick one letter randomly to stretch - for i, letter := range letterforms { - renderedLetterforms[i] = letter(i == stretchIndex) - } - - if spacing > 0 { - // Add spaces between the letters and render. - renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing)) - } - return strings.TrimSpace( - lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...), - ) -} - -// letterC renders the letter C in a stylized way. It takes an integer that -// determines how many cells to stretch the letter. If the stretch is less than -// 1, it defaults to no stretching. -func letterC(stretch bool) string { - // Here's what we're making: - // - // ▄▀▀▀▀ - // █ - // ▀▀▀▀ - - left := heredoc.Doc(` - ▄ - █ - `) - right := heredoc.Doc(` - ▀ - - ▀ - `) - return joinLetterform( - left, - stretchLetterformPart(right, letterformProps{ - stretch: stretch, - width: 4, - minStretch: 7, - maxStretch: 12, - }), - ) -} - -// letterH renders the letter H in a stylized way. It takes an integer that -// determines how many cells to stretch the letter. If the stretch is less than -// 1, it defaults to no stretching. -func letterH(stretch bool) string { - // Here's what we're making: - // - // █ █ - // █▀▀▀█ - // ▀ ▀ - - side := heredoc.Doc(` - █ - █ - ▀`) - middle := heredoc.Doc(` - - ▀ - `) - return joinLetterform( - side, - stretchLetterformPart(middle, letterformProps{ - stretch: stretch, - width: 3, - minStretch: 8, - maxStretch: 12, - }), - side, - ) -} - -// letterR renders the letter R in a stylized way. It takes an integer that -// determines how many cells to stretch the letter. If the stretch is less than -// 1, it defaults to no stretching. -func letterR(stretch bool) string { - // Here's what we're making: - // - // █▀▀▀▄ - // █▀▀▀▄ - // ▀ ▀ - - left := heredoc.Doc(` - █ - █ - ▀ - `) - center := heredoc.Doc(` - ▀ - ▀ - `) - right := heredoc.Doc(` - ▄ - ▄ - ▀ - `) - return joinLetterform( - left, - stretchLetterformPart(center, letterformProps{ - stretch: stretch, - width: 3, - minStretch: 7, - maxStretch: 12, - }), - right, - ) -} - -// letterSStylized renders the letter S in a stylized way, more so than -// [letterS]. It takes an integer that determines how many cells to stretch the -// letter. If the stretch is less than 1, it defaults to no stretching. -func letterSStylized(stretch bool) string { - // Here's what we're making: - // - // ▄▀▀▀▀▀ - // ▀▀▀▀▀█ - // ▀▀▀▀▀ - - left := heredoc.Doc(` - ▄ - ▀ - ▀ - `) - center := heredoc.Doc(` - ▀ - ▀ - ▀ - `) - right := heredoc.Doc(` - ▀ - █ - `) - return joinLetterform( - left, - stretchLetterformPart(center, letterformProps{ - stretch: stretch, - width: 3, - minStretch: 7, - maxStretch: 12, - }), - right, - ) -} - -// letterU renders the letter U in a stylized way. It takes an integer that -// determines how many cells to stretch the letter. If the stretch is less than -// 1, it defaults to no stretching. -func letterU(stretch bool) string { - // Here's what we're making: - // - // █ █ - // █ █ - // ▀▀▀ - - side := heredoc.Doc(` - █ - █ - `) - middle := heredoc.Doc(` - - - ▀ - `) - return joinLetterform( - side, - stretchLetterformPart(middle, letterformProps{ - stretch: stretch, - width: 3, - minStretch: 7, - maxStretch: 12, - }), - side, - ) -} - -func joinLetterform(letters ...string) string { - return lipgloss.JoinHorizontal(lipgloss.Top, letters...) -} - -// letterformProps defines letterform stretching properties. -// for readability. -type letterformProps struct { - width int - minStretch int - maxStretch int - stretch bool -} - -// stretchLetterformPart is a helper function for letter stretching. If randomize -// is false the minimum number will be used. -func stretchLetterformPart(s string, p letterformProps) string { - if p.maxStretch < p.minStretch { - p.minStretch, p.maxStretch = p.maxStretch, p.minStretch - } - n := p.width - if p.stretch { - n = cachedRandN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec - } - parts := make([]string, n) - for i := range parts { - parts[i] = s - } - return lipgloss.JoinHorizontal(lipgloss.Top, parts...) -} diff --git a/internal/tui/components/logo/rand.go b/internal/tui/components/logo/rand.go deleted file mode 100644 index cf79487e23825b468c98a0f27bbc8dbfbb1a7081..0000000000000000000000000000000000000000 --- a/internal/tui/components/logo/rand.go +++ /dev/null @@ -1,24 +0,0 @@ -package logo - -import ( - "math/rand/v2" - "sync" -) - -var ( - randCaches = make(map[int]int) - randCachesMu sync.Mutex -) - -func cachedRandN(n int) int { - randCachesMu.Lock() - defer randCachesMu.Unlock() - - if n, ok := randCaches[n]; ok { - return n - } - - r := rand.IntN(n) - randCaches[n] = r - return r -} diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go deleted file mode 100644 index 3379c2c9acfd7e7e10d6e6777e2554d0b0db2144..0000000000000000000000000000000000000000 --- a/internal/tui/components/lsp/lsp.go +++ /dev/null @@ -1,144 +0,0 @@ -package lsp - -import ( - "fmt" - "maps" - "slices" - "strings" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/app" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/csync" - "github.com/charmbracelet/crush/internal/lsp" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/styles" -) - -// RenderOptions contains options for rendering LSP lists. -type RenderOptions struct { - MaxWidth int - MaxItems int - ShowSection bool - SectionName string -} - -// RenderLSPList renders a list of LSP status items with the given options. -func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions) []string { - t := styles.CurrentTheme() - lspList := []string{} - - if opts.ShowSection { - sectionName := opts.SectionName - if sectionName == "" { - sectionName = "LSPs" - } - section := t.S().Subtle.Render(sectionName) - lspList = append(lspList, section, "") - } - - // 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 - } - - // Determine how many items to show - maxItems := len(lsps) - if opts.MaxItems > 0 { - maxItems = min(opts.MaxItems, len(lsps)) - } - - for i, info := range lsps { - if i >= maxItems { - break - } - - icon, description := iconAndDescription(t, info) - - // Calculate diagnostic counts if we have LSP clients - var extraContent string - if lspClients != nil { - if client, ok := lspClients.Get(info.Name); ok { - counts := client.GetDiagnosticCounts() - errs := []string{} - if counts.Error > 0 { - errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, counts.Error))) - } - if counts.Warning > 0 { - errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, counts.Warning))) - } - if counts.Hint > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, counts.Hint))) - } - if counts.Information > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, counts.Information))) - } - extraContent = strings.Join(errs, " ") - } - } - - lspList = append(lspList, - core.Status( - core.StatusOpts{ - Icon: icon.String(), - Title: info.Name, - Description: description, - ExtraContent: extraContent, - }, - opts.MaxWidth, - ), - ) - } - - return lspList -} - -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...") - case lsp.StateReady: - return t.ItemOnlineIcon, "" - case lsp.StateError: - description := t.S().Subtle.Render("error") - if info.Error != nil { - description = t.S().Subtle.Render(fmt.Sprintf("error: %s", info.Error.Error())) - } - return t.ItemErrorIcon, description - case lsp.StateDisabled: - return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("inactive") - default: - return t.ItemOfflineIcon, "" - } -} - -// RenderLSPBlock renders a complete LSP block with optional truncation indicator. -func RenderLSPBlock(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions, showTruncationIndicator bool) string { - t := styles.CurrentTheme() - lspList := RenderLSPList(lspClients, opts) - - // Add truncation indicator if needed - if showTruncationIndicator && opts.MaxItems > 0 { - lspConfigs := config.Get().LSP.Sorted() - if len(lspConfigs) > opts.MaxItems { - remaining := len(lspConfigs) - opts.MaxItems - if remaining == 1 { - lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…")) - } else { - lspList = append(lspList, - t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), - ) - } - } - } - - content := lipgloss.JoinVertical(lipgloss.Left, lspList...) - if opts.MaxWidth > 0 { - return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content) - } - return content -} diff --git a/internal/tui/components/mcp/mcp.go b/internal/tui/components/mcp/mcp.go deleted file mode 100644 index 78763ac85fdbb5b75e281ef39289f490e6bde949..0000000000000000000000000000000000000000 --- a/internal/tui/components/mcp/mcp.go +++ /dev/null @@ -1,138 +0,0 @@ -package mcp - -import ( - "fmt" - "strings" - - "charm.land/lipgloss/v2" - - "github.com/charmbracelet/crush/internal/agent/tools/mcp" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/styles" -) - -// RenderOptions contains options for rendering MCP lists. -type RenderOptions struct { - MaxWidth int - MaxItems int - ShowSection bool - SectionName string -} - -// RenderMCPList renders a list of MCP status items with the given options. -func RenderMCPList(opts RenderOptions) []string { - t := styles.CurrentTheme() - mcpList := []string{} - - if opts.ShowSection { - sectionName := opts.SectionName - if sectionName == "" { - sectionName = "MCPs" - } - section := t.S().Subtle.Render(sectionName) - mcpList = append(mcpList, section, "") - } - - mcps := config.Get().MCP.Sorted() - if len(mcps) == 0 { - mcpList = append(mcpList, t.S().Base.Foreground(t.Border).Render("None")) - return mcpList - } - - // Get MCP states - mcpStates := mcp.GetStates() - - // Determine how many items to show - maxItems := len(mcps) - if opts.MaxItems > 0 { - maxItems = min(opts.MaxItems, len(mcps)) - } - - for i, l := range mcps { - if i >= maxItems { - break - } - - // Determine icon and color based on state - icon := t.ItemOfflineIcon - description := "" - extraContent := []string{} - - if state, exists := mcpStates[l.Name]; exists { - switch state.State { - case mcp.StateDisabled: - description = t.S().Subtle.Render("disabled") - case mcp.StateStarting: - icon = t.ItemBusyIcon - description = t.S().Subtle.Render("starting...") - case mcp.StateConnected: - icon = t.ItemOnlineIcon - if count := state.Counts.Tools; count > 0 { - label := "tools" - if count == 1 { - label = "tool" - } - extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d %s", count, label))) - } - if count := state.Counts.Prompts; count > 0 { - label := "prompts" - if count == 1 { - label = "prompt" - } - extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d %s", count, label))) - } - case mcp.StateError: - icon = t.ItemErrorIcon - if state.Error != nil { - description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error())) - } else { - description = t.S().Subtle.Render("error") - } - } - } else if l.MCP.Disabled { - description = t.S().Subtle.Render("disabled") - } - - mcpList = append(mcpList, - core.Status( - core.StatusOpts{ - Icon: icon.String(), - Title: l.Name, - Description: description, - ExtraContent: strings.Join(extraContent, " "), - }, - opts.MaxWidth, - ), - ) - } - - return mcpList -} - -// RenderMCPBlock renders a complete MCP block with optional truncation indicator. -func RenderMCPBlock(opts RenderOptions, showTruncationIndicator bool) string { - t := styles.CurrentTheme() - mcpList := RenderMCPList(opts) - - // Add truncation indicator if needed - if showTruncationIndicator && opts.MaxItems > 0 { - mcps := config.Get().MCP.Sorted() - if len(mcps) > opts.MaxItems { - remaining := len(mcps) - opts.MaxItems - if remaining == 1 { - mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…")) - } else { - mcpList = append(mcpList, - t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), - ) - } - } - } - - content := lipgloss.JoinVertical(lipgloss.Left, mcpList...) - if opts.MaxWidth > 0 { - return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content) - } - return content -} diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go deleted file mode 100644 index 8956bfa60dd36cee115cc82ef9ea2adb758219e9..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/filterable.go +++ /dev/null @@ -1,329 +0,0 @@ -package list - -import ( - "regexp" - "slices" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/sahilm/fuzzy" -) - -// Pre-compiled regex for checking if a string is alphanumeric. -var alphanumericRegex = regexp.MustCompile(`^[a-zA-Z0-9]*$`) - -type FilterableItem interface { - Item - FilterValue() string -} - -type FilterableList[T FilterableItem] interface { - List[T] - Cursor() *tea.Cursor - SetInputWidth(int) - SetInputPlaceholder(string) - SetResultsSize(int) - Filter(q string) tea.Cmd - fuzzy.Source -} - -type HasMatchIndexes interface { - MatchIndexes([]int) -} - -type filterableOptions struct { - listOptions []ListOption - placeholder string - inputHidden bool - inputWidth int - inputStyle lipgloss.Style -} -type filterableList[T FilterableItem] struct { - *list[T] - *filterableOptions - width, height int - // stores all available items - items []T - resultsSize int - input textinput.Model - inputWidth int - query string -} - -type filterableListOption func(*filterableOptions) - -func WithFilterPlaceholder(ph string) filterableListOption { - return func(f *filterableOptions) { - f.placeholder = ph - } -} - -func WithFilterInputHidden() filterableListOption { - return func(f *filterableOptions) { - f.inputHidden = true - } -} - -func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption { - return func(f *filterableOptions) { - f.inputStyle = inputStyle - } -} - -func WithFilterListOptions(opts ...ListOption) filterableListOption { - return func(f *filterableOptions) { - f.listOptions = opts - } -} - -func WithFilterInputWidth(inputWidth int) filterableListOption { - return func(f *filterableOptions) { - f.inputWidth = inputWidth - } -} - -func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption) FilterableList[T] { - t := styles.CurrentTheme() - - f := &filterableList[T]{ - filterableOptions: &filterableOptions{ - inputStyle: t.S().Base, - placeholder: "Type to filter", - }, - } - for _, opt := range opts { - opt(f.filterableOptions) - } - f.list = New(items, f.listOptions...).(*list[T]) - - f.updateKeyMaps() - f.items = f.list.items - - if f.inputHidden { - return f - } - - ti := textinput.New() - ti.Placeholder = f.placeholder - ti.SetVirtualCursor(false) - ti.Focus() - ti.SetStyles(t.S().TextInput) - f.input = ti - return f -} - -func (f *filterableList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch { - // handle movements - case key.Matches(msg, f.keyMap.Down), - key.Matches(msg, f.keyMap.Up), - key.Matches(msg, f.keyMap.DownOneItem), - key.Matches(msg, f.keyMap.UpOneItem), - key.Matches(msg, f.keyMap.HalfPageDown), - key.Matches(msg, f.keyMap.HalfPageUp), - key.Matches(msg, f.keyMap.PageDown), - key.Matches(msg, f.keyMap.PageUp), - key.Matches(msg, f.keyMap.End), - key.Matches(msg, f.keyMap.Home): - u, cmd := f.list.Update(msg) - f.list = u.(*list[T]) - return f, cmd - default: - if !f.inputHidden { - var cmds []tea.Cmd - var cmd tea.Cmd - f.input, cmd = f.input.Update(msg) - cmds = append(cmds, cmd) - - if f.query != f.input.Value() { - cmd = f.Filter(f.input.Value()) - cmds = append(cmds, cmd) - } - f.query = f.input.Value() - return f, tea.Batch(cmds...) - } - } - } - u, cmd := f.list.Update(msg) - f.list = u.(*list[T]) - return f, cmd -} - -func (f *filterableList[T]) View() string { - if f.inputHidden { - return f.list.View() - } - - return lipgloss.JoinVertical( - lipgloss.Left, - f.inputStyle.Render(f.input.View()), - f.list.View(), - ) -} - -// removes bindings that are used for search -func (f *filterableList[T]) updateKeyMaps() { - removeLettersAndNumbers := func(bindings []string) []string { - var keep []string - for _, b := range bindings { - if len(b) != 1 { - keep = append(keep, b) - continue - } - if b == " " { - continue - } - m := alphanumericRegex.MatchString(b) - if !m { - keep = append(keep, b) - } - } - return keep - } - - updateBinding := func(binding key.Binding) key.Binding { - newKeys := removeLettersAndNumbers(binding.Keys()) - if len(newKeys) == 0 { - binding.SetEnabled(false) - return binding - } - binding.SetKeys(newKeys...) - return binding - } - - f.keyMap.Down = updateBinding(f.keyMap.Down) - f.keyMap.Up = updateBinding(f.keyMap.Up) - f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem) - f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem) - f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown) - f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp) - f.keyMap.PageDown = updateBinding(f.keyMap.PageDown) - f.keyMap.PageUp = updateBinding(f.keyMap.PageUp) - f.keyMap.End = updateBinding(f.keyMap.End) - f.keyMap.Home = updateBinding(f.keyMap.Home) -} - -func (m *filterableList[T]) GetSize() (int, int) { - return m.width, m.height -} - -func (f *filterableList[T]) SetSize(w, h int) tea.Cmd { - f.width = w - f.height = h - if f.inputHidden { - return f.list.SetSize(w, h) - } - if f.inputWidth == 0 { - f.input.SetWidth(w) - } else { - f.input.SetWidth(f.inputWidth) - } - return f.list.SetSize(w, h-(f.inputHeight())) -} - -func (f *filterableList[T]) inputHeight() int { - return lipgloss.Height(f.inputStyle.Render(f.input.View())) -} - -func (f *filterableList[T]) Filter(query string) tea.Cmd { - var cmds []tea.Cmd - for _, item := range f.items { - if i, ok := any(item).(layout.Focusable); ok { - cmds = append(cmds, i.Blur()) - } - if i, ok := any(item).(HasMatchIndexes); ok { - i.MatchIndexes(make([]int, 0)) - } - } - - f.selectedItemIdx = -1 - if query == "" || len(f.items) == 0 { - return f.list.SetItems(f.visibleItems(f.items)) - } - - matches := fuzzy.FindFrom(query, f) - - var matchedItems []T - resultSize := len(matches) - if f.resultsSize > 0 && resultSize > f.resultsSize { - resultSize = f.resultsSize - } - for i := range resultSize { - match := matches[i] - item := f.items[match.Index] - if it, ok := any(item).(HasMatchIndexes); ok { - it.MatchIndexes(match.MatchedIndexes) - } - matchedItems = append(matchedItems, item) - } - - if f.direction == DirectionBackward { - slices.Reverse(matchedItems) - } - - cmds = append(cmds, f.list.SetItems(matchedItems)) - return tea.Batch(cmds...) -} - -func (f *filterableList[T]) SetItems(items []T) tea.Cmd { - f.items = items - return f.list.SetItems(f.visibleItems(items)) -} - -func (f *filterableList[T]) Cursor() *tea.Cursor { - if f.inputHidden { - return nil - } - return f.input.Cursor() -} - -func (f *filterableList[T]) Blur() tea.Cmd { - f.input.Blur() - return f.list.Blur() -} - -func (f *filterableList[T]) Focus() tea.Cmd { - f.input.Focus() - return f.list.Focus() -} - -func (f *filterableList[T]) IsFocused() bool { - return f.list.IsFocused() -} - -func (f *filterableList[T]) SetInputWidth(w int) { - f.inputWidth = w -} - -func (f *filterableList[T]) SetInputPlaceholder(ph string) { - f.placeholder = ph -} - -func (f *filterableList[T]) SetResultsSize(size int) { - f.resultsSize = size -} - -func (f *filterableList[T]) String(i int) string { - return f.items[i].FilterValue() -} - -func (f *filterableList[T]) Len() int { - return len(f.items) -} - -// visibleItems returns the subset of items that should be rendered based on -// the configured resultsSize limit. The underlying source (f.items) remains -// intact so filtering still searches the full set. -func (f *filterableList[T]) visibleItems(items []T) []T { - if f.resultsSize > 0 && len(items) > f.resultsSize { - return items[:f.resultsSize] - } - return items -} diff --git a/internal/tui/exp/list/filterable_group.go b/internal/tui/exp/list/filterable_group.go deleted file mode 100644 index 8597050cbc3820a53efe467182c8625f608616c2..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/filterable_group.go +++ /dev/null @@ -1,315 +0,0 @@ -package list - -import ( - "regexp" - "sort" - "strings" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/sahilm/fuzzy" -) - -// Pre-compiled regex for checking if a string is alphanumeric. -// Note: This is duplicated from filterable.go to avoid circular dependencies. -var alphanumericRegexGroup = regexp.MustCompile(`^[a-zA-Z0-9]*$`) - -type FilterableGroupList[T FilterableItem] interface { - GroupedList[T] - Cursor() *tea.Cursor - SetInputWidth(int) - SetInputPlaceholder(string) -} -type filterableGroupList[T FilterableItem] struct { - *groupedList[T] - *filterableOptions - width, height int - groups []Group[T] - // stores all available items - input textinput.Model - inputWidth int - query string -} - -func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filterableListOption) FilterableGroupList[T] { - t := styles.CurrentTheme() - - f := &filterableGroupList[T]{ - filterableOptions: &filterableOptions{ - inputStyle: t.S().Base, - placeholder: "Type to filter", - }, - } - for _, opt := range opts { - opt(f.filterableOptions) - } - f.groupedList = NewGroupedList(items, f.listOptions...).(*groupedList[T]) - - f.updateKeyMaps() - - if f.inputHidden { - return f - } - - ti := textinput.New() - ti.Placeholder = f.placeholder - ti.SetVirtualCursor(false) - ti.Focus() - ti.SetStyles(t.S().TextInput) - f.input = ti - return f -} - -func (f *filterableGroupList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch { - // handle movements - case key.Matches(msg, f.keyMap.Down), - key.Matches(msg, f.keyMap.Up), - key.Matches(msg, f.keyMap.DownOneItem), - key.Matches(msg, f.keyMap.UpOneItem), - key.Matches(msg, f.keyMap.HalfPageDown), - key.Matches(msg, f.keyMap.HalfPageUp), - key.Matches(msg, f.keyMap.PageDown), - key.Matches(msg, f.keyMap.PageUp), - key.Matches(msg, f.keyMap.End), - key.Matches(msg, f.keyMap.Home): - u, cmd := f.groupedList.Update(msg) - f.groupedList = u.(*groupedList[T]) - return f, cmd - default: - if !f.inputHidden { - var cmds []tea.Cmd - var cmd tea.Cmd - f.input, cmd = f.input.Update(msg) - cmds = append(cmds, cmd) - - if f.query != f.input.Value() { - cmd = f.Filter(f.input.Value()) - cmds = append(cmds, cmd) - } - f.query = f.input.Value() - return f, tea.Batch(cmds...) - } - } - } - u, cmd := f.groupedList.Update(msg) - f.groupedList = u.(*groupedList[T]) - return f, cmd -} - -func (f *filterableGroupList[T]) View() string { - if f.inputHidden { - return f.groupedList.View() - } - - return lipgloss.JoinVertical( - lipgloss.Left, - f.inputStyle.Render(f.input.View()), - f.groupedList.View(), - ) -} - -// removes bindings that are used for search -func (f *filterableGroupList[T]) updateKeyMaps() { - removeLettersAndNumbers := func(bindings []string) []string { - var keep []string - for _, b := range bindings { - if len(b) != 1 { - keep = append(keep, b) - continue - } - if b == " " { - continue - } - m := alphanumericRegexGroup.MatchString(b) - if !m { - keep = append(keep, b) - } - } - return keep - } - - updateBinding := func(binding key.Binding) key.Binding { - newKeys := removeLettersAndNumbers(binding.Keys()) - if len(newKeys) == 0 { - binding.SetEnabled(false) - return binding - } - binding.SetKeys(newKeys...) - return binding - } - - f.keyMap.Down = updateBinding(f.keyMap.Down) - f.keyMap.Up = updateBinding(f.keyMap.Up) - f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem) - f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem) - f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown) - f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp) - f.keyMap.PageDown = updateBinding(f.keyMap.PageDown) - f.keyMap.PageUp = updateBinding(f.keyMap.PageUp) - f.keyMap.End = updateBinding(f.keyMap.End) - f.keyMap.Home = updateBinding(f.keyMap.Home) -} - -func (m *filterableGroupList[T]) GetSize() (int, int) { - return m.width, m.height -} - -func (f *filterableGroupList[T]) SetSize(w, h int) tea.Cmd { - f.width = w - f.height = h - if f.inputHidden { - return f.groupedList.SetSize(w, h) - } - if f.inputWidth == 0 { - f.input.SetWidth(w) - } else { - f.input.SetWidth(f.inputWidth) - } - return f.groupedList.SetSize(w, h-(f.inputHeight())) -} - -func (f *filterableGroupList[T]) inputHeight() int { - return lipgloss.Height(f.inputStyle.Render(f.input.View())) -} - -func (f *filterableGroupList[T]) clearItemState() []tea.Cmd { - var cmds []tea.Cmd - for _, item := range f.items { - if i, ok := any(item).(layout.Focusable); ok { - cmds = append(cmds, i.Blur()) - } - if i, ok := any(item).(HasMatchIndexes); ok { - i.MatchIndexes(make([]int, 0)) - } - } - return cmds -} - -func (f *filterableGroupList[T]) getGroupName(g Group[T]) string { - if section, ok := g.Section.(*itemSectionModel); ok { - return strings.ToLower(section.title) - } - return strings.ToLower(g.Section.ID()) -} - -func (f *filterableGroupList[T]) setMatchIndexes(item T, indexes []int) { - if i, ok := any(item).(HasMatchIndexes); ok { - i.MatchIndexes(indexes) - } -} - -func (f *filterableGroupList[T]) filterItemsInGroup(group Group[T], query string) []T { - if query == "" { - // No query, return all items with cleared match indexes - var items []T - for _, item := range group.Items { - f.setMatchIndexes(item, make([]int, 0)) - items = append(items, item) - } - return items - } - - name := f.getGroupName(group) + " " - - names := make([]string, len(group.Items)) - for i, item := range group.Items { - names[i] = strings.ToLower(name + item.FilterValue()) - } - - matches := fuzzy.Find(query, names) - sort.SliceStable(matches, func(i, j int) bool { - return matches[i].Score > matches[j].Score - }) - - if len(matches) > 0 { - var matchedItems []T - for _, match := range matches { - item := group.Items[match.Index] - var idxs []int - for _, idx := range match.MatchedIndexes { - // adjusts removing group name highlights - if idx < len(name) { - continue - } - idxs = append(idxs, idx-len(name)) - } - f.setMatchIndexes(item, idxs) - matchedItems = append(matchedItems, item) - } - return matchedItems - } - - return []T{} -} - -func (f *filterableGroupList[T]) Filter(query string) tea.Cmd { - cmds := f.clearItemState() - f.selectedItemIdx = -1 - - if query == "" { - return f.groupedList.SetGroups(f.groups) - } - - query = strings.ToLower(strings.ReplaceAll(query, " ", "")) - - var result []Group[T] - for _, g := range f.groups { - if matches := fuzzy.Find(query, []string{f.getGroupName(g)}); len(matches) > 0 && matches[0].Score > 0 { - result = append(result, g) - continue - } - matchedItems := f.filterItemsInGroup(g, query) - if len(matchedItems) > 0 { - result = append(result, Group[T]{ - Section: g.Section, - Items: matchedItems, - }) - } - } - - cmds = append(cmds, f.groupedList.SetGroups(result)) - return tea.Batch(cmds...) -} - -func (f *filterableGroupList[T]) SetGroups(groups []Group[T]) tea.Cmd { - f.groups = groups - return f.groupedList.SetGroups(groups) -} - -func (f *filterableGroupList[T]) Cursor() *tea.Cursor { - if f.inputHidden { - return nil - } - return f.input.Cursor() -} - -func (f *filterableGroupList[T]) Blur() tea.Cmd { - f.input.Blur() - return f.groupedList.Blur() -} - -func (f *filterableGroupList[T]) Focus() tea.Cmd { - f.input.Focus() - return f.groupedList.Focus() -} - -func (f *filterableGroupList[T]) IsFocused() bool { - return f.groupedList.IsFocused() -} - -func (f *filterableGroupList[T]) SetInputWidth(w int) { - f.inputWidth = w -} - -func (f *filterableGroupList[T]) SetInputPlaceholder(ph string) { - f.input.Placeholder = ph - f.placeholder = ph -} diff --git a/internal/tui/exp/list/filterable_test.go b/internal/tui/exp/list/filterable_test.go deleted file mode 100644 index ce61f2c0f4014c9d16c29675eff7ecbb060b2dfd..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/filterable_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package list - -import ( - "fmt" - "slices" - "testing" - - "github.com/charmbracelet/x/exp/golden" - "github.com/stretchr/testify/assert" -) - -func TestFilterableList(t *testing.T) { - t.Parallel() - t.Run("should create simple filterable list", func(t *testing.T) { - t.Parallel() - items := []FilterableItem{} - for i := range 5 { - item := NewFilterableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := NewFilterableList( - items, - WithFilterListOptions(WithDirectionForward()), - ).(*filterableList[FilterableItem]) - - l.SetSize(100, 10) - cmd := l.Init() - if cmd != nil { - cmd() - } - - assert.Equal(t, 0, l.selectedItemIdx) - golden.RequireEqual(t, []byte(l.View())) - }) -} - -func TestUpdateKeyMap(t *testing.T) { - t.Parallel() - l := NewFilterableList( - []FilterableItem{}, - WithFilterListOptions(WithDirectionForward()), - ).(*filterableList[FilterableItem]) - - hasJ := slices.Contains(l.keyMap.Down.Keys(), "j") - fmt.Println(l.keyMap.Down.Keys()) - hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j") - - hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K") - - assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters") - assert.False(t, hasJ, "should not contain j") - assert.False(t, hasUpperCaseK, "should also remove upper case K") - assert.True(t, hasCtrlJ, "should still have ctrl+j") -} - -type filterableItem struct { - *selectableItem -} - -func NewFilterableItem(content string) FilterableItem { - return &filterableItem{ - selectableItem: NewSelectableItem(content).(*selectableItem), - } -} - -func (f *filterableItem) FilterValue() string { - return f.content -} diff --git a/internal/tui/exp/list/grouped.go b/internal/tui/exp/list/grouped.go deleted file mode 100644 index b1408aa663a4847ad4acaaf89d8b2282cf2b3aab..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/grouped.go +++ /dev/null @@ -1,100 +0,0 @@ -package list - -import ( - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/util" -) - -type Group[T Item] struct { - Section ItemSection - Items []T -} -type GroupedList[T Item] interface { - util.Model - layout.Sizeable - Items() []Item - Groups() []Group[T] - SetGroups([]Group[T]) tea.Cmd - MoveUp(int) tea.Cmd - MoveDown(int) tea.Cmd - GoToTop() tea.Cmd - GoToBottom() tea.Cmd - SelectItemAbove() tea.Cmd - SelectItemBelow() tea.Cmd - SetSelected(string) tea.Cmd - SelectedItem() *T -} -type groupedList[T Item] struct { - *list[Item] - groups []Group[T] -} - -func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T] { - list := &list[Item]{ - confOptions: &confOptions{ - direction: DirectionForward, - keyMap: DefaultKeyMap(), - focused: true, - }, - items: []Item{}, - indexMap: make(map[string]int), - renderedItems: make(map[string]renderedItem), - } - for _, opt := range opts { - opt(list.confOptions) - } - - return &groupedList[T]{ - list: list, - } -} - -func (g *groupedList[T]) Init() tea.Cmd { - g.convertItems() - return g.render() -} - -func (l *groupedList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) { - u, cmd := l.list.Update(msg) - l.list = u.(*list[Item]) - return l, cmd -} - -func (g *groupedList[T]) SelectedItem() *T { - item := g.list.SelectedItem() - if item == nil { - return nil - } - dRef := *item - c, ok := any(dRef).(T) - if !ok { - return nil - } - return &c -} - -func (g *groupedList[T]) convertItems() { - var items []Item - for _, g := range g.groups { - items = append(items, g.Section) - for _, g := range g.Items { - items = append(items, g) - } - } - g.items = items -} - -func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd { - g.groups = groups - g.convertItems() - return g.SetItems(g.items) -} - -func (g *groupedList[T]) Groups() []Group[T] { - return g.groups -} - -func (g *groupedList[T]) Items() []Item { - return g.list.Items() -} diff --git a/internal/tui/exp/list/items.go b/internal/tui/exp/list/items.go deleted file mode 100644 index 3db5635b044d9845915d005dd5f7cdac233fe53f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/items.go +++ /dev/null @@ -1,399 +0,0 @@ -package list - -import ( - "image/color" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" - "github.com/google/uuid" - "github.com/rivo/uniseg" -) - -type Indexable interface { - SetIndex(int) -} - -type CompletionItem[T any] interface { - FilterableItem - layout.Focusable - layout.Sizeable - HasMatchIndexes - Value() T - Text() string -} - -type completionItemCmp[T any] struct { - width int - id string - text string - value T - focus bool - matchIndexes []int - bgColor color.Color - shortcut string -} - -type options struct { - id string - text string - bgColor color.Color - matchIndexes []int - shortcut string -} - -type CompletionItemOption func(*options) - -func WithCompletionBackgroundColor(c color.Color) CompletionItemOption { - return func(cmp *options) { - cmp.bgColor = c - } -} - -func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption { - return func(cmp *options) { - cmp.matchIndexes = indexes - } -} - -func WithCompletionShortcut(shortcut string) CompletionItemOption { - return func(cmp *options) { - cmp.shortcut = shortcut - } -} - -func WithCompletionID(id string) CompletionItemOption { - return func(cmp *options) { - cmp.id = id - } -} - -func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] { - c := &completionItemCmp[T]{ - text: text, - value: value, - } - o := &options{} - - for _, opt := range opts { - opt(o) - } - if o.id == "" { - o.id = uuid.NewString() - } - c.id = o.id - c.bgColor = o.bgColor - c.matchIndexes = o.matchIndexes - c.shortcut = o.shortcut - return c -} - -// Init implements CommandItem. -func (c *completionItemCmp[T]) Init() tea.Cmd { - return nil -} - -// Update implements CommandItem. -func (c *completionItemCmp[T]) Update(tea.Msg) (util.Model, tea.Cmd) { - return c, nil -} - -// View implements CommandItem. -func (c *completionItemCmp[T]) View() string { - t := styles.CurrentTheme() - - itemStyle := t.S().Base.Padding(0, 1).Width(c.width) - innerWidth := c.width - 2 // Account for padding - - if c.shortcut != "" { - innerWidth -= lipgloss.Width(c.shortcut) - } - - titleStyle := t.S().Text.Width(innerWidth) - titleMatchStyle := t.S().Text.Underline(true) - if c.bgColor != nil { - titleStyle = titleStyle.Background(c.bgColor) - titleMatchStyle = titleMatchStyle.Background(c.bgColor) - itemStyle = itemStyle.Background(c.bgColor) - } - - if c.focus { - titleStyle = t.S().TextSelected.Width(innerWidth) - titleMatchStyle = t.S().TextSelected.Underline(true) - itemStyle = itemStyle.Background(t.Primary) - } - - var truncatedTitle string - - if len(c.matchIndexes) > 0 && len(c.text) > innerWidth { - // Smart truncation: ensure the last matching part is visible - truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes) - } else { - // No matches, use regular truncation - truncatedTitle = ansi.Truncate(c.text, innerWidth, "…") - } - - text := titleStyle.Render(truncatedTitle) - if len(c.matchIndexes) > 0 { - var ranges []lipgloss.Range - for _, rng := range matchedRanges(c.matchIndexes) { - // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes. - // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions. - // so we need to adjust it here: - start, stop := bytePosToVisibleCharPos(truncatedTitle, rng) - ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle)) - } - text = lipgloss.StyleRanges(text, ranges...) - } - parts := []string{text} - if c.shortcut != "" { - // Add the shortcut at the end - shortcutStyle := t.S().Muted - if c.focus { - shortcutStyle = t.S().TextSelected - } - parts = append(parts, shortcutStyle.Render(c.shortcut)) - } - item := itemStyle.Render( - lipgloss.JoinHorizontal( - lipgloss.Left, - parts..., - ), - ) - return item -} - -// Blur implements CommandItem. -func (c *completionItemCmp[T]) Blur() tea.Cmd { - c.focus = false - return nil -} - -// Focus implements CommandItem. -func (c *completionItemCmp[T]) Focus() tea.Cmd { - c.focus = true - return nil -} - -// GetSize implements CommandItem. -func (c *completionItemCmp[T]) GetSize() (int, int) { - return c.width, 1 -} - -// IsFocused implements CommandItem. -func (c *completionItemCmp[T]) IsFocused() bool { - return c.focus -} - -// SetSize implements CommandItem. -func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd { - c.width = width - return nil -} - -func (c *completionItemCmp[T]) MatchIndexes(indexes []int) { - c.matchIndexes = indexes -} - -func (c *completionItemCmp[T]) FilterValue() string { - return c.text -} - -func (c *completionItemCmp[T]) Value() T { - return c.value -} - -// smartTruncate implements fzf-style truncation that ensures the last matching part is visible -func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string { - if width <= 0 { - return "" - } - - textLen := ansi.StringWidth(text) - if textLen <= width { - return text - } - - if len(matchIndexes) == 0 { - return ansi.Truncate(text, width, "…") - } - - // Find the last match position - lastMatchPos := matchIndexes[len(matchIndexes)-1] - - // Convert byte position to visual width position - lastMatchVisualPos := 0 - bytePos := 0 - gr := uniseg.NewGraphemes(text) - for bytePos < lastMatchPos && gr.Next() { - bytePos += len(gr.Str()) - lastMatchVisualPos += max(1, gr.Width()) - } - - // Calculate how much space we need for the ellipsis - ellipsisWidth := 1 // "…" character width - availableWidth := width - ellipsisWidth - - // If the last match is within the available width, truncate from the end - if lastMatchVisualPos < availableWidth { - return ansi.Truncate(text, width, "…") - } - - // Calculate the start position to ensure the last match is visible - // We want to show some context before the last match if possible - startVisualPos := max(0, lastMatchVisualPos-availableWidth+1) - - // Convert visual position back to byte position - startBytePos := 0 - currentVisualPos := 0 - gr = uniseg.NewGraphemes(text) - for currentVisualPos < startVisualPos && gr.Next() { - startBytePos += len(gr.Str()) - currentVisualPos += max(1, gr.Width()) - } - - // Extract the substring starting from startBytePos - truncatedText := text[startBytePos:] - - // Truncate to fit width with ellipsis - truncatedText = ansi.Truncate(truncatedText, availableWidth, "") - truncatedText = "…" + truncatedText - return truncatedText -} - -func matchedRanges(in []int) [][2]int { - if len(in) == 0 { - return [][2]int{} - } - current := [2]int{in[0], in[0]} - if len(in) == 1 { - return [][2]int{current} - } - var out [][2]int - for i := 1; i < len(in); i++ { - if in[i] == current[1]+1 { - current[1] = in[i] - } else { - out = append(out, current) - current = [2]int{in[i], in[i]} - } - } - out = append(out, current) - return out -} - -func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { - bytePos, byteStart, byteStop := 0, rng[0], rng[1] - pos, start, stop := 0, 0, 0 - gr := uniseg.NewGraphemes(str) - for byteStart > bytePos { - if !gr.Next() { - break - } - bytePos += len(gr.Str()) - pos += max(1, gr.Width()) - } - start = pos - for byteStop > bytePos { - if !gr.Next() { - break - } - bytePos += len(gr.Str()) - pos += max(1, gr.Width()) - } - stop = pos - return start, stop -} - -// ID implements CompletionItem. -func (c *completionItemCmp[T]) ID() string { - return c.id -} - -func (c *completionItemCmp[T]) Text() string { - return c.text -} - -type ItemSection interface { - Item - layout.Sizeable - Indexable - SetInfo(info string) - Title() string -} -type itemSectionModel struct { - width int - title string - inx int - id string - info string -} - -// ID implements ItemSection. -func (m *itemSectionModel) ID() string { - return m.id -} - -// Title implements ItemSection. -func (m *itemSectionModel) Title() string { - return m.title -} - -func NewItemSection(title string) ItemSection { - return &itemSectionModel{ - title: title, - inx: -1, - id: uuid.NewString(), - } -} - -func (m *itemSectionModel) Init() tea.Cmd { - return nil -} - -func (m *itemSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) { - return m, nil -} - -func (m *itemSectionModel) View() string { - t := styles.CurrentTheme() - title := ansi.Truncate(m.title, m.width-2, "…") - style := t.S().Base.Padding(1, 1, 0, 1) - if m.inx == 0 { - style = style.Padding(0, 1, 0, 1) - } - title = t.S().Muted.Render(title) - section := "" - if m.info != "" { - section = core.SectionWithInfo(title, m.width-2, m.info) - } else { - section = core.Section(title, m.width-2) - } - - return style.Render(section) -} - -func (m *itemSectionModel) GetSize() (int, int) { - return m.width, 1 -} - -func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd { - m.width = width - return nil -} - -func (m *itemSectionModel) IsSectionHeader() bool { - return true -} - -func (m *itemSectionModel) SetInfo(info string) { - m.info = info -} - -func (m *itemSectionModel) SetIndex(inx int) { - m.inx = inx -} diff --git a/internal/tui/exp/list/keys.go b/internal/tui/exp/list/keys.go deleted file mode 100644 index e470fbfbea2ea9f958949ebdfabe5fd679192f9c..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/keys.go +++ /dev/null @@ -1,76 +0,0 @@ -package list - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - Down, - Up, - DownOneItem, - UpOneItem, - PageDown, - PageUp, - HalfPageDown, - HalfPageUp, - Home, - End key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Down: key.NewBinding( - key.WithKeys("down", "ctrl+j", "ctrl+n", "j"), - key.WithHelp("↓", "down"), - ), - Up: key.NewBinding( - key.WithKeys("up", "ctrl+k", "ctrl+p", "k"), - key.WithHelp("↑", "up"), - ), - UpOneItem: key.NewBinding( - key.WithKeys("shift+up", "K"), - key.WithHelp("shift+↑", "up one item"), - ), - DownOneItem: key.NewBinding( - key.WithKeys("shift+down", "J"), - key.WithHelp("shift+↓", "down one item"), - ), - HalfPageDown: key.NewBinding( - key.WithKeys("d"), - key.WithHelp("d", "half page down"), - ), - PageDown: key.NewBinding( - key.WithKeys("pgdown", " ", "f"), - key.WithHelp("f/pgdn", "page down"), - ), - PageUp: key.NewBinding( - key.WithKeys("pgup", "b"), - key.WithHelp("b/pgup", "page up"), - ), - HalfPageUp: key.NewBinding( - key.WithKeys("u"), - key.WithHelp("u", "half page up"), - ), - Home: key.NewBinding( - key.WithKeys("g", "home"), - key.WithHelp("g", "home"), - ), - End: key.NewBinding( - key.WithKeys("G", "end"), - key.WithHelp("G", "end"), - ), - } -} - -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Down, - k.Up, - k.DownOneItem, - k.UpOneItem, - k.HalfPageDown, - k.HalfPageUp, - k.Home, - k.End, - } -} diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go deleted file mode 100644 index 653dcd7b2389d50ec19b4dc0f005f7a423e14012..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/list.go +++ /dev/null @@ -1,1775 +0,0 @@ -package list - -import ( - "strings" - "sync" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/components/anim" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/exp/ordered" - "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 - ID() string -} - -type HasAnim interface { - Item - Spinning() bool -} - -type List[T Item] interface { - util.Model - layout.Sizeable - layout.Focusable - - MoveUp(int) tea.Cmd - MoveDown(int) tea.Cmd - GoToTop() tea.Cmd - GoToBottom() tea.Cmd - SelectItemAbove() tea.Cmd - SelectItemBelow() tea.Cmd - SetItems([]T) tea.Cmd - SetSelected(string) tea.Cmd - SelectedItem() *T - Items() []T - UpdateItem(string, T) tea.Cmd - DeleteItem(string) tea.Cmd - PrependItem(T) tea.Cmd - AppendItem(T) tea.Cmd - StartSelection(col, line int) - EndSelection(col, line int) - SelectionStop() - SelectionClear() - SelectWord(col, line int) - SelectParagraph(col, line int) - GetSelectedText(paddingLeft int) string - HasSelection() bool -} - -type direction int - -const ( - DirectionForward direction = iota - DirectionBackward -) - -const ( - ItemNotFound = -1 - ViewportDefaultScrollSize = 5 -) - -type renderedItem struct { - view string - height int - start int - end int -} - -type confOptions struct { - 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 { - *confOptions - - offset int - - indexMap map[string]int - items []T - renderedItems map[string]renderedItem - - rendered string - renderedHeight int // cached height of rendered content - lineOffsets []int // cached byte offsets for each line (for fast slicing) - - cachedView string - cachedViewOffset int - cachedViewDirty bool - - movingByItem bool - prevSelectedItemIdx int // Index of previously selected item (-1 if none) - selectionStartCol int - selectionStartLine int - selectionEndCol int - selectionEndLine int - - selectionActive bool -} - -type ListOption func(*confOptions) - -// WithSize sets the size of the list. -func WithSize(width, height int) ListOption { - return func(l *confOptions) { - l.width = width - l.height = height - } -} - -// WithGap sets the gap between items in the list. -func WithGap(gap int) ListOption { - return func(l *confOptions) { - l.gap = gap - } -} - -// WithDirectionForward sets the direction to forward -func WithDirectionForward() ListOption { - return func(l *confOptions) { - l.direction = DirectionForward - } -} - -// WithDirectionBackward sets the direction to forward -func WithDirectionBackward() ListOption { - return func(l *confOptions) { - l.direction = DirectionBackward - } -} - -// WithSelectedItem sets the initially selected item in the list. -func WithSelectedItem(id string) ListOption { - return func(l *confOptions) { - l.selectedItemID = id // Will be resolved to index in New() - } -} - -func WithKeyMap(keyMap KeyMap) ListOption { - return func(l *confOptions) { - l.keyMap = keyMap - } -} - -func WithWrapNavigation() ListOption { - return func(l *confOptions) { - l.wrap = true - } -} - -func WithFocus(focus bool) ListOption { - return func(l *confOptions) { - l.focused = focus - } -} - -func WithResizeByList() ListOption { - return func(l *confOptions) { - l.resize = true - } -} - -func WithEnableMouse() ListOption { - return func(l *confOptions) { - l.enableMouse = true - } -} - -func New[T Item](items []T, opts ...ListOption) List[T] { - list := &list[T]{ - confOptions: &confOptions{ - direction: DirectionForward, - keyMap: DefaultKeyMap(), - focused: true, - selectedItemIdx: -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) - } - - for inx, item := range items { - if i, ok := any(item).(Indexable); ok { - i.SetIndex(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 -} - -// Init implements List. -func (l *list[T]) Init() tea.Cmd { - return l.render() -} - -// Update implements List. -func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.MouseWheelMsg: - if l.enableMouse { - return l.handleMouseWheel(msg) - } - 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 - 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)) - } - } - } - return l, tea.Batch(cmds...) - case tea.KeyPressMsg: - if l.focused { - switch { - case key.Matches(msg, l.keyMap.Down): - return l, l.MoveDown(ViewportDefaultScrollSize) - case key.Matches(msg, l.keyMap.Up): - return l, l.MoveUp(ViewportDefaultScrollSize) - case key.Matches(msg, l.keyMap.DownOneItem): - return l, l.SelectItemBelow() - case key.Matches(msg, l.keyMap.UpOneItem): - return l, l.SelectItemAbove() - case key.Matches(msg, l.keyMap.HalfPageDown): - return l, l.MoveDown(l.height / 2) - case key.Matches(msg, l.keyMap.HalfPageUp): - return l, l.MoveUp(l.height / 2) - case key.Matches(msg, l.keyMap.PageDown): - return l, l.MoveDown(l.height) - case key.Matches(msg, l.keyMap.PageUp): - return l, l.MoveUp(l.height) - case key.Matches(msg, l.keyMap.End): - return l, l.GoToBottom() - case key.Matches(msg, l.keyMap.Home): - return l, l.GoToTop() - } - s := l.SelectedItem() - if s == nil { - return l, nil - } - item := *s - var cmds []tea.Cmd - updated, cmd := item.Update(msg) - cmds = append(cmds, cmd) - if u, ok := updated.(T); ok { - cmds = append(cmds, l.UpdateItem(u.ID(), u)) - } - return l, tea.Batch(cmds...) - } - } - return l, nil -} - -func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd) { - var cmd tea.Cmd - switch msg.Button { - case tea.MouseWheelDown: - cmd = l.MoveDown(ViewportDefaultScrollSize) - case tea.MouseWheelUp: - cmd = l.MoveUp(ViewportDefaultScrollSize) - } - return l, cmd -} - -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) - scr := uv.NewScreenBuffer(area.Dx(), area.Dy()) - uv.NewStyledString(view).Draw(scr, area) - - selArea := l.selectionArea(false) - specialChars := getSpecialCharsMap() - selStyle := uv.Style{ - Bg: t.TextSelection.GetBackground(), - Fg: t.TextSelection.GetForeground(), - } - - isNonWhitespace := func(r rune) bool { - return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r' - } - - type selectionBounds struct { - startX, endX int - inSelection bool - } - lineSelections := make([]selectionBounds, scr.Height()) - - for y := range scr.Height() { - bounds := selectionBounds{startX: -1, endX: -1, inSelection: false} - - if y >= selArea.Min.Y && y < selArea.Max.Y { - bounds.inSelection = true - if selArea.Min.Y == selArea.Max.Y-1 { - // Single line selection - bounds.startX = selArea.Min.X - bounds.endX = selArea.Max.X - } else if y == selArea.Min.Y { - // First line of multi-line selection - bounds.startX = selArea.Min.X - bounds.endX = scr.Width() - } else if y == selArea.Max.Y-1 { - // Last line of multi-line selection - bounds.startX = 0 - bounds.endX = selArea.Max.X - } else { - // Middle lines - bounds.startX = 0 - bounds.endX = scr.Width() - } - } - lineSelections[y] = bounds - } - - type lineBounds struct { - start, end int - } - lineTextBounds := make([]lineBounds, scr.Height()) - - // First pass: find text bounds for lines that have selections - for y := range scr.Height() { - bounds := lineBounds{start: -1, end: -1} - - // Only process lines that might have selections - if lineSelections[y].inSelection { - for x := range scr.Width() { - cell := scr.CellAt(x, y) - if cell == nil { - continue - } - - cellStr := cell.String() - if len(cellStr) == 0 { - continue - } - - char := rune(cellStr[0]) - _, isSpecial := specialChars[cellStr] - - if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil { - if bounds.start == -1 { - bounds.start = x - } - bounds.end = x + 1 // Position after last character - } - } - } - lineTextBounds[y] = bounds - } - - var selectedText strings.Builder - - // Second pass: apply selection highlighting - for y := range scr.Height() { - selBounds := lineSelections[y] - if !selBounds.inSelection { - continue - } - - textBounds := lineTextBounds[y] - if textBounds.start < 0 { - if textOnly { - // We don't want to get rid of all empty lines in text-only mode - selectedText.WriteByte('\n') - } - - continue // No text on this line - } - - // Only scan within the intersection of text bounds and selection bounds - scanStart := max(textBounds.start, selBounds.startX) - scanEnd := min(textBounds.end, selBounds.endX) - - for x := scanStart; x < scanEnd; x++ { - cell := scr.CellAt(x, y) - if cell == nil { - continue - } - - cellStr := cell.String() - if len(cellStr) > 0 { - if _, isSpecial := specialChars[cellStr]; isSpecial { - continue - } - if textOnly { - // Collect selected text without styles - selectedText.WriteString(cell.String()) - continue - } - - cell = cell.Clone() - cell.Style.Bg = selStyle.Bg - cell.Style.Fg = selStyle.Fg - scr.SetCell(x, y, cell) - } - } - - if textOnly { - // Make sure we add a newline after each line of selected text - selectedText.WriteByte('\n') - } - } - - if textOnly { - return strings.TrimSpace(selectedText.String()) - } - - return scr.Render() -} - -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() - - start, end := l.viewPosition() - viewStart := max(0, start) - viewEnd := end - - if viewStart > viewEnd { - return "" - } - - view := l.getLines(viewStart, viewEnd) - - if l.resize { - return view - } - - view = t.S().Base. - Height(l.height). - Width(l.width). - Render(view) - - if !l.hasSelection() { - l.cachedView = view - l.cachedViewOffset = l.offset - l.cachedViewDirty = false - return view - } - - return l.selectionView(view, false) -} - -func (l *list[T]) viewPosition() (int, int) { - start, end := 0, 0 - renderedLines := l.renderedHeight - 1 - if l.direction == DirectionForward { - start = max(0, l.offset) - end = min(l.offset+l.height-1, renderedLines) - } else { - start = max(0, renderedLines-l.offset-l.height+1) - end = max(0, renderedLines-l.offset) - } - start = min(start, end) - 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() { - 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[item.ID()] = rItem - currentContentHeight = rItem.end + 1 + l.gap - } -} - -func (l *list[T]) render() tea.Cmd { - if l.width <= 0 || l.height <= 0 || len(l.items) == 0 { - return nil - } - l.setDefaultSelected() - - var focusChangeCmd tea.Cmd - if l.focused { - focusChangeCmd = l.focusSelectedItem() - } else { - focusChangeCmd = l.blurSelectedItem() - } - if l.rendered != "" { - rendered, _ := l.renderIterator(0, false, "") - l.setRendered(rendered) - if l.direction == DirectionBackward { - l.recalculateItemPositions() - } - if l.focused { - l.scrollToSelection() - } - return focusChangeCmd - } - rendered, finishIndex := l.renderIterator(0, true, "") - l.setRendered(rendered) - if l.direction == DirectionBackward { - l.recalculateItemPositions() - } - - l.offset = 0 - rendered, _ = l.renderIterator(finishIndex, false, l.rendered) - l.setRendered(rendered) - if l.direction == DirectionBackward { - l.recalculateItemPositions() - } - if l.focused { - l.scrollToSelection() - } - - return focusChangeCmd -} - -func (l *list[T]) setDefaultSelected() { - if l.selectedItemIdx < 0 { - if l.direction == DirectionForward { - l.selectFirstItem() - } else { - l.selectLastItem() - } - } -} - -func (l *list[T]) scrollToSelection() { - 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.selectedItemIdx = -1 - l.setDefaultSelected() - return - } - - start, end := l.viewPosition() - if rItem.start <= start && rItem.end >= end { - return - } - if l.movingByItem { - if rItem.start >= start && rItem.end <= end { - return - } - defer func() { l.movingByItem = false }() - } else { - if rItem.start >= start && rItem.start <= end { - return - } - if rItem.end >= start && rItem.end <= end { - return - } - } - - if rItem.height >= l.height { - if l.direction == DirectionForward { - l.offset = rItem.start - } else { - l.offset = max(0, l.renderedHeight-(rItem.start+l.height)) - } - return - } - - renderedLines := l.renderedHeight - 1 - - if rItem.start < start { - if l.direction == DirectionForward { - l.offset = rItem.start - } else { - l.offset = max(0, renderedLines-rItem.start-l.height+1) - } - } else if rItem.end > end { - if l.direction == DirectionForward { - l.offset = max(0, rItem.end-l.height+1) - } else { - l.offset = max(0, renderedLines-rItem.end) - } - } -} - -func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd { - 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 - } - start, end := l.viewPosition() - // item bigger than the viewport do nothing - if rItem.start <= start && rItem.end >= end { - return nil - } - // item already in view do nothing - if rItem.start >= start && rItem.end <= end { - return nil - } - - itemMiddle := rItem.start + rItem.height/2 - - if itemMiddle < start { - // select the first item in the viewport - // the item is most likely an item coming after this item - inx := l.selectedItemIdx - for { - inx = l.firstSelectableItemBelow(inx) - if inx == ItemNotFound { - return nil - } - if inx < 0 || inx >= len(l.items) { - continue - } - - 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.selectedItemIdx = inx - return l.render() - } - // item is in the view - if renderedItem.start >= start && renderedItem.start <= end { - 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 := l.selectedItemIdx - for { - inx = l.firstSelectableItemAbove(inx) - if inx == ItemNotFound { - return nil - } - if inx < 0 || inx >= len(l.items) { - continue - } - - 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.selectedItemIdx = inx - return l.render() - } - // item is in the view - if renderedItem.end >= start && renderedItem.end <= end { - l.selectedItemIdx = inx - return l.render() - } - } - } - return nil -} - -func (l *list[T]) selectFirstItem() { - inx := l.firstSelectableItemBelow(-1) - if inx != ItemNotFound { - l.selectedItemIdx = inx - } -} - -func (l *list[T]) selectLastItem() { - inx := l.firstSelectableItemAbove(len(l.items)) - if inx != ItemNotFound { - l.selectedItemIdx = inx - } -} - -func (l *list[T]) firstSelectableItemAbove(inx int) int { - unfocusableCount := 0 - for i := inx - 1; i >= 0; i-- { - if i < 0 || i >= len(l.items) { - continue - } - - item := l.items[i] - if _, ok := any(item).(layout.Focusable); ok { - return i - } - unfocusableCount++ - } - if unfocusableCount == inx && l.wrap { - return l.firstSelectableItemAbove(len(l.items)) - } - return ItemNotFound -} - -func (l *list[T]) firstSelectableItemBelow(inx int) int { - unfocusableCount := 0 - itemsLen := len(l.items) - for i := inx + 1; i < itemsLen; i++ { - if i < 0 || i >= len(l.items) { - continue - } - - item := l.items[i] - if _, ok := any(item).(layout.Focusable); ok { - return i - } - unfocusableCount++ - } - if unfocusableCount == itemsLen-inx-1 && l.wrap { - return l.firstSelectableItemBelow(-1) - } - return ItemNotFound -} - -func (l *list[T]) focusSelectedItem() tea.Cmd { - if l.selectedItemIdx < 0 || !l.focused { - return nil - } - // 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.selectedItemIdx < 0 || l.focused { - return nil - } - - // 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 nil -} - -// renderFragment holds updated rendered view fragments -type renderFragment struct { - view string - gap int -} - -// renderIterator renders items starting from the specific index and limits height if limitHeight != -1 -// 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) { - // 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 - finalIndex := itemsLen - - // first pass: accumulate all fragments to render until the height limit is - // reached - for i := startInx; i < itemsLen; i++ { - if limitHeight && currentContentHeight >= l.height { - finalIndex = i - break - } - // cool way to go through the list in both directions - inx := i - - if l.direction != DirectionForward { - inx = (itemsLen - 1) - i - } - - if inx < 0 || inx >= len(l.items) { - continue - } - - item := l.items[inx] - - var rItem renderedItem - 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[item.ID()] = rItem - } - - gap := l.gap + 1 - if inx == itemsLen-1 { - gap = 0 - } - - fragments = append(fragments, renderFragment{view: rItem.view, gap: gap}) - - currentContentHeight = rItem.end + 1 + l.gap - } - - // 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 i := range fragments { - f := &fragments[i] - b.WriteString(f.view) - // 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)) - } - } - } - - return b.String(), finalIndex - } - - // iterate backwards as fragments are in reversed order - for i := len(fragments) - 1; i >= 0; i-- { - f := &fragments[i] - b.WriteString(f.view) - // 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) - - return b.String(), finalIndex -} - -func (l *list[T]) renderItem(item Item) renderedItem { - view := item.View() - return renderedItem{ - view: view, - height: lipgloss.Height(view), - } -} - -// AppendItem implements List. -func (l *list[T]) AppendItem(item T) tea.Cmd { - // Pre-allocate with expected capacity - cmds := make([]tea.Cmd, 0, 4) - cmd := item.Init() - if cmd != nil { - cmds = append(cmds, cmd) - } - - 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 { - cmds = append(cmds, cmd) - } - } - cmd = l.render() - if cmd != nil { - cmds = append(cmds, cmd) - } - if l.direction == DirectionBackward { - if l.offset == 0 { - cmd = l.GoToBottom() - if cmd != nil { - cmds = append(cmds, cmd) - } - } else { - newItem, ok := l.renderedItems[item.ID()] - if ok { - newLines := newItem.height - if len(l.items) > 1 { - newLines += l.gap - } - l.offset = min(l.renderedHeight-1, l.offset+newLines) - } - } - } - return tea.Sequence(cmds...) -} - -// Blur implements List. -func (l *list[T]) Blur() tea.Cmd { - l.focused = false - return l.render() -} - -// DeleteItem implements List. -func (l *list[T]) DeleteItem(id string) tea.Cmd { - inx, ok := l.indexMap[id] - if !ok { - return nil - } - 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 - } - } - - // 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 { - l.selectedItemIdx = inx - 1 - } else { - 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 != "" { - if l.renderedHeight <= l.height { - l.offset = 0 - } else { - maxOffset := l.renderedHeight - l.height - if l.offset > maxOffset { - l.offset = maxOffset - } - } - } - return cmd -} - -// Focus implements List. -func (l *list[T]) Focus() tea.Cmd { - l.focused = true - return l.render() -} - -// GetSize implements List. -func (l *list[T]) GetSize() (int, int) { - return l.width, l.height -} - -// GoToBottom implements List. -func (l *list[T]) GoToBottom() tea.Cmd { - l.offset = 0 - l.selectedItemIdx = -1 - l.direction = DirectionBackward - return l.render() -} - -// GoToTop implements List. -func (l *list[T]) GoToTop() tea.Cmd { - l.offset = 0 - l.selectedItemIdx = -1 - l.direction = DirectionForward - return l.render() -} - -// IsFocused implements List. -func (l *list[T]) IsFocused() bool { - return l.focused -} - -// Items implements List. -func (l *list[T]) Items() []T { - 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) { - // no need for offset - if l.renderedHeight <= l.height { - return - } - 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) { - n = min(n, l.offset) - if n <= 0 { - return - } - l.offset -= n - if l.offset < 0 { - l.offset = 0 - } - l.cachedViewDirty = true -} - -// MoveDown implements List. -func (l *list[T]) MoveDown(n int) tea.Cmd { - oldOffset := l.offset - if l.direction == DirectionForward { - l.incrementOffset(n) - } else { - l.decrementOffset(n) - } - - if oldOffset == l.offset { - // no change in offset, so no need to change selection - return nil - } - // if we are not actively selecting move the whole selection down - if l.hasSelection() && !l.selectionActive { - if l.selectionStartLine < l.selectionEndLine { - l.selectionStartLine -= n - l.selectionEndLine -= n - } else { - l.selectionStartLine -= n - l.selectionEndLine -= n - } - } - if l.selectionActive { - if l.selectionStartLine < l.selectionEndLine { - l.selectionStartLine -= n - } else { - l.selectionEndLine -= n - } - } - return l.changeSelectionWhenScrolling() -} - -// MoveUp implements List. -func (l *list[T]) MoveUp(n int) tea.Cmd { - oldOffset := l.offset - if l.direction == DirectionForward { - l.decrementOffset(n) - } else { - l.incrementOffset(n) - } - - if oldOffset == l.offset { - // no change in offset, so no need to change selection - return nil - } - - if l.hasSelection() && !l.selectionActive { - if l.selectionStartLine > l.selectionEndLine { - l.selectionStartLine += n - l.selectionEndLine += n - } else { - l.selectionStartLine += n - l.selectionEndLine += n - } - } - if l.selectionActive { - if l.selectionStartLine > l.selectionEndLine { - l.selectionStartLine += n - } else { - l.selectionEndLine += n - } - } - return l.changeSelectionWhenScrolling() -} - -// PrependItem implements List. -func (l *list[T]) PrependItem(item T) tea.Cmd { - // 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++ - } - - // 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)) - } - cmds = append(cmds, l.render()) - if l.direction == DirectionForward { - if l.offset == 0 { - cmd := l.GoToTop() - if cmd != nil { - cmds = append(cmds, cmd) - } - } else { - newItem, ok := l.renderedItems[item.ID()] - if ok { - newLines := newItem.height - if len(l.items) > 1 { - newLines += l.gap - } - l.offset = min(l.renderedHeight-1, l.offset+newLines) - } - } - } - return tea.Batch(cmds...) -} - -// SelectItemAbove implements List. -func (l *list[T]) SelectItemAbove() tea.Cmd { - if l.selectedItemIdx < 0 { - return nil - } - - newIndex := l.firstSelectableItemAbove(l.selectedItemIdx) - if newIndex == ItemNotFound { - // no item above - return nil - } - // Pre-allocate with expected capacity - cmds := make([]tea.Cmd, 0, 2) - if newIndex > l.selectedItemIdx && l.selectedItemIdx > 0 && l.offset > 0 { - // this means there is a section above and not showing on the top, move to the top - newIndex = l.selectedItemIdx - cmd := l.GoToTop() - if cmd != nil { - cmds = append(cmds, cmd) - } - } - if newIndex == 1 { - peakAboveIndex := l.firstSelectableItemAbove(newIndex) - if peakAboveIndex == ItemNotFound { - // this means there is a section above move to the top - cmd := l.GoToTop() - if cmd != nil { - cmds = append(cmds, cmd) - } - } - } - if newIndex < 0 || newIndex >= len(l.items) { - return nil - } - l.prevSelectedItemIdx = l.selectedItemIdx - l.selectedItemIdx = newIndex - l.movingByItem = true - renderCmd := l.render() - if renderCmd != nil { - cmds = append(cmds, renderCmd) - } - return tea.Sequence(cmds...) -} - -// SelectItemBelow implements List. -func (l *list[T]) SelectItemBelow() tea.Cmd { - if l.selectedItemIdx < 0 { - return nil - } - - newIndex := l.firstSelectableItemBelow(l.selectedItemIdx) - if newIndex == ItemNotFound { - // no item below - return nil - } - if newIndex < 0 || newIndex >= len(l.items) { - return nil - } - if newIndex < l.selectedItemIdx { - // reset offset when wrap to the top to show the top section if it exists - l.offset = 0 - } - l.prevSelectedItemIdx = l.selectedItemIdx - l.selectedItemIdx = newIndex - l.movingByItem = true - return l.render() -} - -// SelectedItem implements List. -func (l *list[T]) SelectedItem() *T { - 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 = items - var cmds []tea.Cmd - for inx, item := range items { - if i, ok := any(item).(Indexable); ok { - i.SetIndex(inx) - } - cmds = append(cmds, item.Init()) - } - cmds = append(cmds, l.reset("")) - return tea.Batch(cmds...) -} - -// SetSelected implements List. -func (l *list[T]) SetSelected(id string) tea.Cmd { - 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(selectedItemID string) tea.Cmd { - var cmds []tea.Cmd - l.rendered = "" - l.renderedHeight = 0 - l.offset = 0 - 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...) -} - -// SetSize implements List. -func (l *list[T]) SetSize(width int, height int) tea.Cmd { - oldWidth := l.width - oldHeight := l.height - l.width = width - l.height = height - // Invalidate cache if height changed - if oldHeight != height { - l.cachedViewDirty = true - } - if oldWidth != width { - // 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 -} - -// UpdateItem implements List. -func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { - // 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 = (l.renderedHeight - 1) - l.offset - } - - delete(l.renderedItems, id) - cmd := l.render() - - // need to check for nil because of sequence not handling nil - if cmd != nil { - cmds = append(cmds, cmd) - } - if hasOldItem && l.direction == DirectionBackward { - // 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[item.ID()] - if ok { - newLines := newItem.height - oldItem.height - l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1) - } - } - } else if hasOldItem && l.offset > oldItem.start { - newItem, ok := l.renderedItems[item.ID()] - if ok { - newLines := newItem.height - oldItem.height - l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1) - } - } - } - return tea.Sequence(cmds...) -} - -func (l *list[T]) hasSelection() bool { - return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine -} - -// StartSelection implements List. -func (l *list[T]) StartSelection(col, line int) { - l.selectionStartCol = col - l.selectionStartLine = line - l.selectionEndCol = col - l.selectionEndLine = line - l.selectionActive = true -} - -// EndSelection implements List. -func (l *list[T]) EndSelection(col, line int) { - if !l.selectionActive { - return - } - l.selectionEndCol = col - l.selectionEndLine = line -} - -func (l *list[T]) SelectionStop() { - l.selectionActive = false -} - -func (l *list[T]) SelectionClear() { - l.selectionStartCol = -1 - l.selectionStartLine = -1 - l.selectionEndCol = -1 - l.selectionEndLine = -1 - l.selectionActive = false -} - -func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) { - numLines := l.lineCount() - - if l.direction == DirectionBackward && numLines > l.height { - line = ((numLines - 1) - l.height) + line + 1 - } - - if l.offset > 0 { - if l.direction == DirectionBackward { - line -= l.offset - } else { - line += l.offset - } - } - - if line < 0 || line >= numLines { - return 0, 0 - } - - currentLine := ansi.Strip(l.getLine(line)) - gr := uniseg.NewGraphemes(currentLine) - startCol = -1 - upTo := col - for gr.Next() { - if gr.IsWordBoundary() && upTo > 0 { - startCol = col - upTo + 1 - } else if gr.IsWordBoundary() && upTo < 0 { - endCol = col - upTo + 1 - break - } - if upTo == 0 && gr.Str() == " " { - return 0, 0 - } - upTo -= 1 - } - if startCol == -1 { - return 0, 0 - } - return startCol, endCol -} - -func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) { - // 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 { - cleanLine = strings.ReplaceAll(cleanLine, icon, " ") - } - return cleanLine - } - - numLines := l.lineCount() - if l.direction == DirectionBackward && numLines > l.height { - line = (numLines - 1) - l.height + line + 1 - } - - if l.offset > 0 { - if l.direction == DirectionBackward { - line -= l.offset - } else { - line += l.offset - } - } - - // Ensure line is within bounds - if line < 0 || line >= numLines { - return 0, 0, false - } - - 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(getCleanLine(startLine-1)) != "" { - startLine-- - } - - // Find end of paragraph (search forwards for empty line or end of text) - endLine = line - for endLine < numLines-1 && strings.TrimSpace(getCleanLine(endLine+1)) != "" { - endLine++ - } - - // revert the line numbers if we are in backward direction - 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 { - startLine += l.offset - endLine += l.offset - } else { - startLine -= l.offset - endLine -= l.offset - } - } - return startLine, endLine, true -} - -// SelectWord selects the word at the given position. -func (l *list[T]) SelectWord(col, line int) { - startCol, endCol := l.findWordBoundaries(col, line) - l.selectionStartCol = startCol - l.selectionStartLine = line - l.selectionEndCol = endCol - l.selectionEndLine = line - l.selectionActive = false // Not actively selecting, just selected -} - -// SelectParagraph selects the paragraph at the given position. -func (l *list[T]) SelectParagraph(col, line int) { - startLine, endLine, found := l.findParagraphBoundaries(line) - if !found { - return - } - l.selectionStartCol = 0 - l.selectionStartLine = startLine - l.selectionEndCol = l.width - 1 - l.selectionEndLine = endLine - l.selectionActive = false // Not actively selecting, just selected -} - -// HasSelection returns whether there is an active selection. -func (l *list[T]) HasSelection() bool { - return l.hasSelection() -} - -func (l *list[T]) selectionArea(absolute bool) uv.Rectangle { - var startY int - if absolute { - startY, _ = l.viewPosition() - } - selArea := uv.Rectangle{ - Min: uv.Pos(l.selectionStartCol, l.selectionStartLine+startY), - Max: uv.Pos(l.selectionEndCol, l.selectionEndLine+startY), - } - selArea = selArea.Canon() - selArea.Max.Y++ // make max Y exclusive - return selArea -} - -// GetSelectedText returns the currently selected text. -func (l *list[T]) GetSelectedText(paddingLeft int) string { - if !l.hasSelection() { - return "" - } - - selArea := l.selectionArea(true) - if selArea.Empty() { - return "" - } - - selectionHeight := selArea.Dy() - - tempBuf := uv.NewScreenBuffer(l.width, selectionHeight) - tempBufArea := tempBuf.Bounds() - renderedLines := l.getLines(selArea.Min.Y, selArea.Max.Y) - styled := uv.NewStyledString(renderedLines) - styled.Draw(tempBuf, tempBufArea) - - // XXX: Left padding assumes the list component is rendered with absolute - // positioning. The chat component has a left margin of 1 and items in the - // list have a border of 1 plus a padding of 1. The paddingLeft parameter - // assumes this total left padding of 3 and we should fix that. - leftBorder := paddingLeft - 1 - - var b strings.Builder - for y := tempBufArea.Min.Y; y < tempBufArea.Max.Y; y++ { - var pending strings.Builder - for x := tempBufArea.Min.X + leftBorder; x < tempBufArea.Max.X; { - cell := tempBuf.CellAt(x, y) - if cell == nil || cell.IsZero() { - x++ - continue - } - if y == 0 && x < selArea.Min.X { - x++ - continue - } - if y == selectionHeight-1 && x > selArea.Max.X-1 { - break - } - if cell.Width == 1 && cell.Content == " " { - pending.WriteString(cell.Content) - x++ - continue - } - b.WriteString(pending.String()) - pending.Reset() - b.WriteString(cell.Content) - x += cell.Width - } - if y < tempBufArea.Max.Y-1 { - b.WriteByte('\n') - } - } - - return b.String() -} diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go deleted file mode 100644 index 57ca7883f87e9facf82b46f60f66f2101a08428a..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/list_test.go +++ /dev/null @@ -1,653 +0,0 @@ -package list - -import ( - "fmt" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/exp/golden" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestList(t *testing.T) { - t.Parallel() - t.Run("should have correct positions in list that fits the items", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 5 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 0, l.selectedItemIdx) - assert.Equal(t, 0, l.offset) - 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[items[i].ID()] - require.True(t, ok) - assert.Equal(t, i, item.start) - assert.Equal(t, i, item.end) - } - - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 5 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 4, l.selectedItemIdx) - assert.Equal(t, 0, l.offset) - 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[items[i].ID()] - require.True(t, ok) - assert.Equal(t, i, item.start) - assert.Equal(t, i, item.end) - } - - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 0, l.selectedItemIdx) - assert.Equal(t, 0, l.offset) - 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[items[i].ID()] - require.True(t, ok) - assert.Equal(t, i, item.start) - assert.Equal(t, i, item.end) - } - - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 29, l.selectedItemIdx) - assert.Equal(t, 0, l.offset) - 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[items[i].ID()] - require.True(t, ok) - assert.Equal(t, i, item.start) - assert.Equal(t, i, item.end) - } - - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 0, l.selectedItemIdx) - assert.Equal(t, 0, l.offset) - 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 - } - assert.Equal(t, expectedLines, 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) - currentPosition := 0 - for i := range 30 { - rItem, ok := l.renderedItems[items[i].ID()] - require.True(t, ok) - assert.Equal(t, currentPosition, rItem.start) - assert.Equal(t, currentPosition+i, rItem.end) - currentPosition += i + 1 - } - - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 29, l.selectedItemIdx) - assert.Equal(t, 0, l.offset) - 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 - } - assert.Equal(t, expectedLines, 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, expectedLines-10, start) - assert.Equal(t, expectedLines-1, end) - currentPosition := 0 - for i := range 30 { - rItem, ok := l.renderedItems[items[i].ID()] - require.True(t, ok) - assert.Equal(t, currentPosition, rItem.start) - assert.Equal(t, currentPosition+i, rItem.end) - currentPosition += i + 1 - } - - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should go to selected item at the beginning", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 10, l.selectedItemIdx) - - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should go to selected item at the beginning backwards", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, 10, l.selectedItemIdx) - - golden.RequireEqual(t, []byte(l.View())) - }) -} - -func TestListMovement(t *testing.T) { - t.Parallel() - t.Run("should move viewport up", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveUp(25)) - - assert.Equal(t, 25, l.offset) - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should move viewport up and down", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveUp(25)) - execCmd(l, l.MoveDown(25)) - - assert.Equal(t, 0, l.offset) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should move viewport down", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveDown(25)) - - assert.Equal(t, 25, l.offset) - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should move viewport down and up", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveDown(25)) - execCmd(l, l.MoveUp(25)) - - assert.Equal(t, 0, l.offset) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - execCmd(l, l.AppendItem(NewSelectableItem("Testing"))) - - assert.Equal(t, 0, l.offset) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should stay at the position it is when new items are added but we moved up in backwards list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveUp(2)) - viewBefore := l.View() - execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 5, l.offset) - assert.Equal(t, 33, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should stay at the position it is when the hight of an item below is increased in backwards list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveUp(2)) - viewBefore := l.View() - item := items[29] - execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 4, l.offset) - assert.Equal(t, 32, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should stay at the position it is when the hight of an item below is decreases in backwards list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3")) - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveUp(2)) - viewBefore := l.View() - item := items[30] - execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 0, l.offset) - assert.Equal(t, 31, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should stay at the position it is when the hight of an item above is increased in backwards list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveUp(2)) - viewBefore := l.View() - item := items[1] - execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 2, l.offset) - assert.Equal(t, 32, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveUp(2)) - viewBefore := l.View() - execCmd(l, l.PrependItem(NewSelectableItem("New"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 2, l.offset) - assert.Equal(t, 31, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1) - content = strings.TrimSuffix(content, "\n") - item := NewSelectableItem(content) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - execCmd(l, l.PrependItem(NewSelectableItem("Testing"))) - - assert.Equal(t, 0, l.offset) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should stay at the position it is when new items are added but we moved down in forward list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveDown(2)) - viewBefore := l.View() - execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 5, l.offset) - assert.Equal(t, 33, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should stay at the position it is when the hight of an item above is increased in forward list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveDown(2)) - viewBefore := l.View() - item := items[0] - execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 4, l.offset) - assert.Equal(t, 32, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should stay at the position it is when the hight of an item above is decreases in forward list", func(t *testing.T) { - t.Parallel() - items := []Item{} - items = append(items, NewSelectableItem("At top\nLine 2\nLine 3")) - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveDown(3)) - viewBefore := l.View() - item := items[0] - execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 1, l.offset) - assert.Equal(t, 31, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should stay at the position it is when the hight of an item below is increased in forward list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveDown(2)) - viewBefore := l.View() - item := items[29] - execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 2, l.offset) - assert.Equal(t, 32, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) { - t.Parallel() - items := []Item{} - for i := range 30 { - item := NewSelectableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) - execCmd(l, l.Init()) - - execCmd(l, l.MoveDown(2)) - viewBefore := l.View() - execCmd(l, l.AppendItem(NewSelectableItem("New"))) - viewAfter := l.View() - assert.Equal(t, viewBefore, viewAfter) - assert.Equal(t, 2, l.offset) - assert.Equal(t, 31, lipgloss.Height(l.rendered)) - golden.RequireEqual(t, []byte(l.View())) - }) -} - -type SelectableItem interface { - Item - layout.Focusable -} - -type simpleItem struct { - width int - content string - id string -} -type selectableItem struct { - *simpleItem - focused bool -} - -func NewSimpleItem(content string) *simpleItem { - return &simpleItem{ - id: uuid.NewString(), - width: 0, - content: content, - } -} - -func NewSelectableItem(content string) SelectableItem { - return &selectableItem{ - simpleItem: NewSimpleItem(content), - focused: false, - } -} - -func (s *simpleItem) ID() string { - return s.id -} - -func (s *simpleItem) Init() tea.Cmd { - return nil -} - -func (s *simpleItem) Update(msg tea.Msg) (util.Model, tea.Cmd) { - return s, nil -} - -func (s *simpleItem) View() string { - return lipgloss.NewStyle().Width(s.width).Render(s.content) -} - -func (l *simpleItem) GetSize() (int, int) { - return l.width, 0 -} - -// SetSize implements Item. -func (s *simpleItem) SetSize(width int, height int) tea.Cmd { - s.width = width - return nil -} - -func (s *selectableItem) View() string { - if s.focused { - return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content) - } - return lipgloss.NewStyle().Width(s.width).Render(s.content) -} - -// Blur implements SimpleItem. -func (s *selectableItem) Blur() tea.Cmd { - s.focused = false - return nil -} - -// Focus implements SimpleItem. -func (s *selectableItem) Focus() tea.Cmd { - s.focused = true - return nil -} - -// IsFocused implements SimpleItem. -func (s *selectableItem) IsFocused() bool { - return s.focused -} - -func execCmd(m util.Model, cmd tea.Cmd) { - for cmd != nil { - msg := cmd() - m, cmd = m.Update(msg) - } -} diff --git a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden deleted file mode 100644 index 01668d35b2d07b73b1daf709578d1dccf72a4cea..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -> Type to filter  -│Item 0  -Item 1  -Item 2  -Item 3  -Item 4  - - - - \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden deleted file mode 100644 index 7775902a7b151f55d9182fe2af00bd1a0f8e261b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden deleted file mode 100644 index 7775902a7b151f55d9182fe2af00bd1a0f8e261b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden deleted file mode 100644 index 4eb402d4d275af1e95c28c538b0059f75fd15a88..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden deleted file mode 100644 index f167f64ffd978440b6df4f59911c384ed0538a66..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 0 -Item 1 -Item 1 -Item 2 -Item 2 -Item 2 -Item 3 -Item 3 -Item 3 -Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden deleted file mode 100644 index d54f38ec7432b9f24930015a7415aa3604b97025..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden deleted file mode 100644 index aaa3c01a3e5cec4da20bdb25af8bc9c86d8ccfd5..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -Item 27 -Item 28 -│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden deleted file mode 100644 index a11b23ef049201e56929376a6638bd12718b7a3f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden +++ /dev/null @@ -1,20 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden deleted file mode 100644 index 55b683ef02e235e03bbe941093d557dd06dfd888..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden +++ /dev/null @@ -1,20 +0,0 @@ -Item 0 -Item 1 -Item 2 -Item 3 -│Item 4 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden deleted file mode 100644 index d304f35cc7594d9070555ff914980787b7cfb987..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 6 -Item 6 -Item 6 -│Item 7 -│Item 7 -│Item 7 -│Item 7 -│Item 7 -│Item 7 -│Item 7 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden deleted file mode 100644 index 65c98367d817411de97cfae7a34737efe0217d6b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 0 -Item 1 -Item 1 -Item 2 -Item 2 -Item 2 -│Item 3 -│Item 3 -│Item 3 -│Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden deleted file mode 100644 index 03582cc911ee2f3d50e428cd320c25a13c99147b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 28 -│Item 28 -│Item 28 -│Item 28 -│Item 28 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden deleted file mode 100644 index d54f38ec7432b9f24930015a7415aa3604b97025..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden deleted file mode 100644 index 8cea66d71fb8e43fc9e0ac8fcb6ee1000cfcb5e4..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -│Testing  \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden deleted file mode 100644 index faed253a104304630e9e33decc445622cde8739a..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Testing  -Item 0 -Item 1 -Item 1 -Item 2 -Item 2 -Item 2 -Item 3 -Item 3 -Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden deleted file mode 100644 index 9ac6e51a8a45f645d7e7f10dc4ea0542155e198e..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden deleted file mode 100644 index 1a5650ba234a86b20584a146124d7b0c8023679f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden deleted file mode 100644 index 9ac6e51a8a45f645d7e7f10dc4ea0542155e198e..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden deleted file mode 100644 index 1a5650ba234a86b20584a146124d7b0c8023679f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden deleted file mode 100644 index 4eb402d4d275af1e95c28c538b0059f75fd15a88..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden deleted file mode 100644 index 1a5650ba234a86b20584a146124d7b0c8023679f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden deleted file mode 100644 index 9ac6e51a8a45f645d7e7f10dc4ea0542155e198e..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden deleted file mode 100644 index f377a4fd04f868d775c279849fd65723afaac901..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -Item 27 -Item 28 -│Item 29 -Item 30 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden deleted file mode 100644 index 1a5650ba234a86b20584a146124d7b0c8023679f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden deleted file mode 100644 index 9ac6e51a8a45f645d7e7f10dc4ea0542155e198e..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file diff --git a/internal/tui/highlight/highlight.go b/internal/tui/highlight/highlight.go deleted file mode 100644 index c8cf833056603d18e6bd7ecac8f27a6652fdfde7..0000000000000000000000000000000000000000 --- a/internal/tui/highlight/highlight.go +++ /dev/null @@ -1,54 +0,0 @@ -package highlight - -import ( - "bytes" - "image/color" - - "github.com/alecthomas/chroma/v2" - "github.com/alecthomas/chroma/v2/formatters" - "github.com/alecthomas/chroma/v2/lexers" - chromaStyles "github.com/alecthomas/chroma/v2/styles" - "github.com/charmbracelet/crush/internal/tui/styles" -) - -func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) { - // Determine the language lexer to use - l := lexers.Match(fileName) - if l == nil { - l = lexers.Analyse(source) - } - if l == nil { - l = lexers.Fallback - } - l = chroma.Coalesce(l) - - // Get the formatter - f := formatters.Get("terminal16m") - if f == nil { - f = formatters.Fallback - } - - style := chroma.MustNewStyle("crush", styles.GetChromaTheme()) - - // Modify the style to use the provided background - s, err := style.Builder().Transform( - func(t chroma.StyleEntry) chroma.StyleEntry { - r, g, b, _ := bg.RGBA() - t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8)) - return t - }, - ).Build() - if err != nil { - s = chromaStyles.Fallback - } - - // Tokenize and format - it, err := l.Tokenise(nil, source) - if err != nil { - return "", err - } - - var buf bytes.Buffer - err = f.Format(&buf, s, it) - return buf.String(), err -} diff --git a/internal/tui/keys.go b/internal/tui/keys.go deleted file mode 100644 index bee9a3063ed375819298e01098524f15247ba280..0000000000000000000000000000000000000000 --- a/internal/tui/keys.go +++ /dev/null @@ -1,45 +0,0 @@ -package tui - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - Quit key.Binding - Help key.Binding - Commands key.Binding - Suspend key.Binding - Models key.Binding - Sessions key.Binding - - pageBindings []key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Quit: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - Help: key.NewBinding( - key.WithKeys("ctrl+g"), - key.WithHelp("ctrl+g", "more"), - ), - Commands: key.NewBinding( - key.WithKeys("ctrl+p"), - key.WithHelp("ctrl+p", "commands"), - ), - Suspend: key.NewBinding( - key.WithKeys("ctrl+z"), - key.WithHelp("ctrl+z", "suspend"), - ), - Models: key.NewBinding( - key.WithKeys("ctrl+l", "ctrl+m"), - key.WithHelp("ctrl+l", "models"), - ), - Sessions: key.NewBinding( - key.WithKeys("ctrl+s"), - key.WithHelp("ctrl+s", "sessions"), - ), - } -} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go deleted file mode 100644 index bb2eb755bf80995dd41d9ac564174de5b90262bb..0000000000000000000000000000000000000000 --- a/internal/tui/page/chat/chat.go +++ /dev/null @@ -1,1407 +0,0 @@ -package chat - -import ( - "context" - "errors" - "fmt" - "time" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/app" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/history" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/crush/internal/pubsub" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/components/anim" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/chat/editor" - "github.com/charmbracelet/crush/internal/tui/components/chat/header" - "github.com/charmbracelet/crush/internal/tui/components/chat/messages" - "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar" - "github.com/charmbracelet/crush/internal/tui/components/chat/splash" - "github.com/charmbracelet/crush/internal/tui/components/completions" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning" - "github.com/charmbracelet/crush/internal/tui/page" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/crush/internal/version" -) - -var ChatPageID page.PageID = "chat" - -type ( - ChatFocusedMsg struct { - Focused bool - } - CancelTimerExpiredMsg struct{} -) - -type PanelType string - -const ( - PanelTypeChat PanelType = "chat" - PanelTypeEditor PanelType = "editor" - PanelTypeSplash PanelType = "splash" -) - -// PillSection represents which pill section is focused when in pills panel. -type PillSection int - -const ( - PillSectionTodos PillSection = iota - PillSectionQueue -) - -const ( - CompactModeWidthBreakpoint = 120 // Width at which the chat page switches to compact mode - CompactModeHeightBreakpoint = 30 // Height at which the chat page switches to compact mode - EditorHeight = 5 // Height of the editor input area including padding - SideBarWidth = 31 // Width of the sidebar - SideBarDetailsPadding = 1 // Padding for the sidebar details section - HeaderHeight = 1 // Height of the header - - // Layout constants for borders and padding - BorderWidth = 1 // Width of component borders - LeftRightBorders = 2 // Left + right border width (1 + 1) - TopBottomBorders = 2 // Top + bottom border width (1 + 1) - DetailsPositioning = 2 // Positioning adjustment for details panel - - // Timing constants - CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires -) - -type ChatPage interface { - util.Model - layout.Help - IsChatFocused() bool -} - -// cancelTimerCmd creates a command that expires the cancel timer -func cancelTimerCmd() tea.Cmd { - return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg { - return CancelTimerExpiredMsg{} - }) -} - -type chatPage struct { - width, height int - detailsWidth, detailsHeight int - app *app.App - keyboardEnhancements tea.KeyboardEnhancementsMsg - - // Layout state - compact bool - forceCompact bool - focusedPane PanelType - - // Session - session session.Session - keyMap KeyMap - - // Components - header header.Header - sidebar sidebar.Sidebar - chat chat.MessageListCmp - editor editor.Editor - splash splash.Splash - - // Simple state flags - showingDetails bool - isCanceling bool - splashFullScreen bool - isOnboarding bool - isProjectInit bool - promptQueue int - - // Pills state - pillsExpanded bool - focusedPillSection PillSection - - // Todo spinner - todoSpinner spinner.Model -} - -func New(app *app.App) ChatPage { - t := styles.CurrentTheme() - return &chatPage{ - app: app, - keyMap: DefaultKeyMap(), - header: header.New(app.LSPClients), - sidebar: sidebar.New(app.History, app.LSPClients, false), - chat: chat.New(app), - editor: editor.New(app), - splash: splash.New(), - focusedPane: PanelTypeSplash, - todoSpinner: spinner.New( - spinner.WithSpinner(spinner.MiniDot), - spinner.WithStyle(t.S().Base.Foreground(t.GreenDark)), - ), - } -} - -func (p *chatPage) Init() tea.Cmd { - cfg := config.Get() - compact := cfg.Options.TUI.CompactMode - p.compact = compact - p.forceCompact = compact - p.sidebar.SetCompactMode(p.compact) - - // Set splash state based on config - if !config.HasInitialDataConfig() { - // First-time setup: show model selection - p.splash.SetOnboarding(true) - p.isOnboarding = true - p.splashFullScreen = true - } else if b, _ := config.ProjectNeedsInitialization(); b { - // Project needs context initialization - p.splash.SetProjectInit(true) - p.isProjectInit = true - p.splashFullScreen = true - } else { - // Ready to chat: focus editor, splash in background - p.focusedPane = PanelTypeEditor - p.splashFullScreen = false - } - - return tea.Batch( - p.header.Init(), - p.sidebar.Init(), - p.chat.Init(), - p.editor.Init(), - p.splash.Init(), - ) -} - -func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { - var cmds []tea.Cmd - if p.session.ID != "" && p.app.AgentCoordinator != nil { - queueSize := p.app.AgentCoordinator.QueuedPrompts(p.session.ID) - if queueSize != p.promptQueue { - p.promptQueue = queueSize - cmds = append(cmds, p.SetSize(p.width, p.height)) - } - } - switch msg := msg.(type) { - case tea.KeyboardEnhancementsMsg: - p.keyboardEnhancements = msg - return p, nil - case tea.MouseWheelMsg: - if p.compact { - msg.Y -= 1 - } - if p.isMouseOverChat(msg.X, msg.Y) { - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - } - return p, nil - case tea.MouseClickMsg: - if p.isOnboarding || p.isProjectInit { - return p, nil - } - if p.compact { - msg.Y -= 1 - } - if p.isMouseOverChat(msg.X, msg.Y) { - p.focusedPane = PanelTypeChat - p.chat.Focus() - p.editor.Blur() - } else { - p.focusedPane = PanelTypeEditor - p.editor.Focus() - p.chat.Blur() - } - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - case tea.MouseMotionMsg: - if p.compact { - msg.Y -= 1 - } - if msg.Button == tea.MouseLeft { - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - } - return p, nil - case tea.MouseReleaseMsg: - if p.isOnboarding || p.isProjectInit { - return p, nil - } - if p.compact { - msg.Y -= 1 - } - if msg.Button == tea.MouseLeft { - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - } - return p, nil - case chat.SelectionCopyMsg: - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - case tea.WindowSizeMsg: - u, cmd := p.editor.Update(msg) - p.editor = u.(editor.Editor) - return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd) - case CancelTimerExpiredMsg: - p.isCanceling = false - return p, nil - case editor.OpenEditorMsg: - u, cmd := p.editor.Update(msg) - p.editor = u.(editor.Editor) - return p, cmd - case chat.SendMsg: - return p, p.sendMessage(msg.Text, msg.Attachments) - case chat.SessionSelectedMsg: - return p, p.setSession(msg) - case splash.SubmitAPIKeyMsg: - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - case commands.ToggleCompactModeMsg: - p.forceCompact = !p.forceCompact - var cmd tea.Cmd - if p.forceCompact { - p.setCompactMode(true) - cmd = p.updateCompactConfig(true) - } else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint { - p.setCompactMode(false) - cmd = p.updateCompactConfig(false) - } - return p, tea.Batch(p.SetSize(p.width, p.height), cmd) - case commands.ToggleThinkingMsg: - return p, p.toggleThinking() - case commands.OpenReasoningDialogMsg: - return p, p.openReasoningDialog() - case reasoning.ReasoningEffortSelectedMsg: - return p, p.handleReasoningEffortSelected(msg.Effort) - case commands.OpenExternalEditorMsg: - u, cmd := p.editor.Update(msg) - p.editor = u.(editor.Editor) - return p, cmd - case pubsub.Event[session.Session]: - if msg.Payload.ID == p.session.ID { - prevHasIncompleteTodos := hasIncompleteTodos(p.session.Todos) - prevHasInProgress := p.hasInProgressTodo() - p.session = msg.Payload - newHasIncompleteTodos := hasIncompleteTodos(p.session.Todos) - newHasInProgress := p.hasInProgressTodo() - if prevHasIncompleteTodos != newHasIncompleteTodos { - cmds = append(cmds, p.SetSize(p.width, p.height)) - } - if !prevHasInProgress && newHasInProgress { - cmds = append(cmds, p.todoSpinner.Tick) - } - } - u, cmd := p.header.Update(msg) - p.header = u.(header.Header) - cmds = append(cmds, cmd) - u, cmd = p.sidebar.Update(msg) - p.sidebar = u.(sidebar.Sidebar) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - case chat.SessionClearedMsg: - u, cmd := p.header.Update(msg) - p.header = u.(header.Header) - cmds = append(cmds, cmd) - u, cmd = p.sidebar.Update(msg) - p.sidebar = u.(sidebar.Sidebar) - cmds = append(cmds, 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, - completions.SelectCompletionMsg: - u, cmd := p.editor.Update(msg) - p.editor = u.(editor.Editor) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - - case hyper.DeviceFlowCompletedMsg, - hyper.DeviceAuthInitiatedMsg, - hyper.DeviceFlowErrorMsg, - copilot.DeviceAuthInitiatedMsg, - copilot.DeviceFlowErrorMsg, - copilot.DeviceFlowCompletedMsg: - if p.focusedPane == PanelTypeSplash { - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - cmds = append(cmds, cmd) - } - return p, tea.Batch(cmds...) - case models.APIKeyStateChangeMsg: - if p.focusedPane == PanelTypeSplash { - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - cmds = append(cmds, cmd) - } - return p, tea.Batch(cmds...) - case pubsub.Event[message.Message], - anim.StepMsg, - spinner.TickMsg: - // Update todo spinner if agent is busy and we have in-progress todos - agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() - if _, ok := msg.(spinner.TickMsg); ok && p.hasInProgressTodo() && agentBusy { - var cmd tea.Cmd - p.todoSpinner, cmd = p.todoSpinner.Update(msg) - cmds = append(cmds, cmd) - } - // Start spinner when agent becomes busy and we have in-progress todos - if _, ok := msg.(pubsub.Event[message.Message]); ok && p.hasInProgressTodo() && agentBusy { - cmds = append(cmds, p.todoSpinner.Tick) - } - if p.focusedPane == PanelTypeSplash { - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - cmds = append(cmds, cmd) - } else { - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - cmds = append(cmds, cmd) - } - - return p, tea.Batch(cmds...) - case commands.ToggleYoloModeMsg: - // update the editor style - u, cmd := p.editor.Update(msg) - p.editor = u.(editor.Editor) - return p, cmd - case pubsub.Event[history.File], sidebar.SessionFilesMsg: - u, cmd := p.sidebar.Update(msg) - p.sidebar = u.(sidebar.Sidebar) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - case pubsub.Event[permission.PermissionNotification]: - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - - case commands.CommandRunCustomMsg: - if p.app.AgentCoordinator.IsBusy() { - return p, util.ReportWarn("Agent is busy, please wait before executing a command...") - } - - cmd := p.sendMessage(msg.Content, nil) - if cmd != nil { - return p, cmd - } - case splash.OnboardingCompleteMsg: - p.splashFullScreen = false - if b, _ := config.ProjectNeedsInitialization(); b { - p.splash.SetProjectInit(true) - p.splashFullScreen = true - return p, p.SetSize(p.width, p.height) - } - err := p.app.InitCoderAgent(context.TODO()) - if err != nil { - return p, util.ReportError(err) - } - p.isOnboarding = false - p.isProjectInit = false - p.focusedPane = PanelTypeEditor - return p, p.SetSize(p.width, p.height) - case commands.NewSessionsMsg: - if p.app.AgentCoordinator.IsBusy() { - return p, util.ReportWarn("Agent is busy, please wait before starting a new session...") - } - return p, p.newSession() - case tea.KeyPressMsg: - switch { - case key.Matches(msg, p.keyMap.NewSession): - // if we have no agent do nothing - if p.app.AgentCoordinator == nil { - return p, nil - } - if p.app.AgentCoordinator.IsBusy() { - return p, util.ReportWarn("Agent is busy, please wait before starting a new session...") - } - return p, p.newSession() - case key.Matches(msg, p.keyMap.AddAttachment): - // Skip attachment handling during onboarding/splash screen - if p.focusedPane == PanelTypeSplash || p.isOnboarding { - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - return p, cmd - } - agentCfg := config.Get().Agents[config.AgentCoder] - model := config.Get().GetModelByType(agentCfg.Model) - if model == nil { - return p, util.ReportWarn("No model configured yet") - } - if model.SupportsImages { - return p, util.CmdHandler(commands.OpenFilePickerMsg{}) - } else { - return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name) - } - case key.Matches(msg, p.keyMap.Tab): - if p.session.ID == "" { - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - return p, cmd - } - return p, p.changeFocus() - case key.Matches(msg, p.keyMap.Cancel): - if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() { - return p, p.cancel() - } - case key.Matches(msg, p.keyMap.Details): - p.toggleDetails() - return p, nil - case key.Matches(msg, p.keyMap.TogglePills): - if p.session.ID != "" { - return p, p.togglePillsExpanded() - } - case key.Matches(msg, p.keyMap.PillLeft): - if p.session.ID != "" && p.pillsExpanded { - return p, p.switchPillSection(-1) - } - case key.Matches(msg, p.keyMap.PillRight): - if p.session.ID != "" && p.pillsExpanded { - return p, p.switchPillSection(1) - } - } - - switch p.focusedPane { - case PanelTypeChat: - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - cmds = append(cmds, cmd) - case PanelTypeEditor: - u, cmd := p.editor.Update(msg) - p.editor = u.(editor.Editor) - cmds = append(cmds, cmd) - case PanelTypeSplash: - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - cmds = append(cmds, cmd) - } - case tea.PasteMsg: - switch p.focusedPane { - case PanelTypeEditor: - u, cmd := p.editor.Update(msg) - p.editor = u.(editor.Editor) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - case PanelTypeChat: - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - case PanelTypeSplash: - u, cmd := p.splash.Update(msg) - p.splash = u.(splash.Splash) - cmds = append(cmds, cmd) - return p, tea.Batch(cmds...) - } - } - return p, tea.Batch(cmds...) -} - -func (p *chatPage) Cursor() *tea.Cursor { - if p.header.ShowingDetails() { - return nil - } - switch p.focusedPane { - case PanelTypeEditor: - return p.editor.Cursor() - case PanelTypeSplash: - return p.splash.Cursor() - default: - return nil - } -} - -func (p *chatPage) View() string { - var chatView string - t := styles.CurrentTheme() - - if p.session.ID == "" { - splashView := p.splash.View() - // Full screen during onboarding or project initialization - if p.splashFullScreen { - chatView = splashView - } else { - // Show splash + editor for new message state - editorView := p.editor.View() - chatView = lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Render(splashView), - editorView, - ) - } - } else { - messagesView := p.chat.View() - editorView := p.editor.View() - - hasIncompleteTodos := hasIncompleteTodos(p.session.Todos) - hasQueue := p.promptQueue > 0 - todosFocused := p.pillsExpanded && p.focusedPillSection == PillSectionTodos - queueFocused := p.pillsExpanded && p.focusedPillSection == PillSectionQueue - - // Use spinner when agent is busy, otherwise show static icon - agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() - inProgressIcon := t.S().Base.Foreground(t.GreenDark).Render(styles.CenterSpinnerIcon) - if agentBusy { - inProgressIcon = p.todoSpinner.View() - } - - var pills []string - if hasIncompleteTodos { - pills = append(pills, todoPill(p.session.Todos, inProgressIcon, todosFocused, p.pillsExpanded, t)) - } - if hasQueue { - pills = append(pills, queuePill(p.promptQueue, queueFocused, p.pillsExpanded, t)) - } - - var expandedList string - if p.pillsExpanded { - if todosFocused && hasIncompleteTodos { - expandedList = todoList(p.session.Todos, inProgressIcon, t, p.width-SideBarWidth) - } else if queueFocused && hasQueue { - queueItems := p.app.AgentCoordinator.QueuedPromptsList(p.session.ID) - expandedList = queueList(queueItems, t) - } - } - - var pillsArea string - if len(pills) > 0 { - pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...) - - // Add help hint for expanding/collapsing pills based on state. - var helpDesc string - if p.pillsExpanded { - helpDesc = "close" - } else { - helpDesc = "open" - } - // Style to match help section: keys in FgMuted, description in FgSubtle - helpKey := t.S().Base.Foreground(t.FgMuted).Render("ctrl+space") - helpText := t.S().Base.Foreground(t.FgSubtle).Render(helpDesc) - helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText) - pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint) - - if expandedList != "" { - pillsArea = lipgloss.JoinVertical( - lipgloss.Left, - pillsRow, - expandedList, - ) - } else { - pillsArea = pillsRow - } - - pillsArea = t.S().Base. - MaxWidth(p.width). - MarginTop(1). - PaddingLeft(3). - Render(pillsArea) - } - - if p.compact { - headerView := p.header.View() - views := []string{headerView, messagesView} - if pillsArea != "" { - views = append(views, pillsArea) - } - views = append(views, editorView) - chatView = lipgloss.JoinVertical(lipgloss.Left, views...) - } else { - sidebarView := p.sidebar.View() - var messagesColumn string - if pillsArea != "" { - messagesColumn = lipgloss.JoinVertical( - lipgloss.Left, - messagesView, - pillsArea, - ) - } else { - messagesColumn = messagesView - } - messages := lipgloss.JoinHorizontal( - lipgloss.Left, - messagesColumn, - sidebarView, - ) - chatView = lipgloss.JoinVertical( - lipgloss.Left, - messages, - p.editor.View(), - ) - } - } - - layers := []*lipgloss.Layer{ - lipgloss.NewLayer(chatView).X(0).Y(0), - } - - if p.showingDetails { - style := t.S().Base. - Width(p.detailsWidth). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) - version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version) - details := style.Render( - lipgloss.JoinVertical( - lipgloss.Left, - p.sidebar.View(), - version, - ), - ) - layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1)) - } - canvas := lipgloss.NewCompositor(layers...) - return canvas.Render() -} - -func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd { - return func() tea.Msg { - err := config.Get().SetCompactMode(compact) - if err != nil { - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: "Failed to update compact mode configuration: " + err.Error(), - } - } - return nil - } -} - -func (p *chatPage) toggleThinking() tea.Cmd { - return func() tea.Msg { - cfg := config.Get() - agentCfg := cfg.Agents[config.AgentCoder] - currentModel := cfg.Models[agentCfg.Model] - - // Toggle the thinking mode - currentModel.Think = !currentModel.Think - if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil { - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: "Failed to update thinking mode: " + err.Error(), - } - } - - // Update the agent with the new configuration - go p.app.UpdateAgentModel(context.TODO()) - - status := "disabled" - if currentModel.Think { - status = "enabled" - } - return util.InfoMsg{ - Type: util.InfoTypeInfo, - Msg: "Thinking mode " + status, - } - } -} - -func (p *chatPage) openReasoningDialog() tea.Cmd { - return func() tea.Msg { - cfg := config.Get() - agentCfg := cfg.Agents[config.AgentCoder] - model := cfg.GetModelByType(agentCfg.Model) - providerCfg := cfg.GetProviderForModel(agentCfg.Model) - - if providerCfg != nil && model != nil && len(model.ReasoningLevels) > 0 { - // Return the OpenDialogMsg directly so it bubbles up to the main TUI - return dialogs.OpenDialogMsg{ - Model: reasoning.NewReasoningDialog(), - } - } - return nil - } -} - -func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd { - return func() tea.Msg { - cfg := config.Get() - agentCfg := cfg.Agents[config.AgentCoder] - currentModel := cfg.Models[agentCfg.Model] - - // Update the model configuration - currentModel.ReasoningEffort = effort - if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil { - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: "Failed to update reasoning effort: " + err.Error(), - } - } - - // Update the agent with the new configuration - if err := p.app.UpdateAgentModel(context.TODO()); err != nil { - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: "Failed to update reasoning effort: " + err.Error(), - } - } - - return util.InfoMsg{ - Type: util.InfoTypeInfo, - Msg: "Reasoning effort set to " + effort, - } - } -} - -func (p *chatPage) setCompactMode(compact bool) { - if p.compact == compact { - return - } - p.compact = compact - if compact { - p.sidebar.SetCompactMode(true) - } else { - p.setShowDetails(false) - } -} - -func (p *chatPage) handleCompactMode(newWidth int, newHeight int) { - if p.forceCompact { - return - } - if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact { - p.setCompactMode(true) - } - if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact { - p.setCompactMode(false) - } -} - -func (p *chatPage) SetSize(width, height int) tea.Cmd { - p.handleCompactMode(width, height) - p.width = width - p.height = height - var cmds []tea.Cmd - - if p.session.ID == "" { - if p.splashFullScreen { - cmds = append(cmds, p.splash.SetSize(width, height)) - } else { - cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight)) - cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) - cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight)) - } - } else { - hasIncompleteTodos := hasIncompleteTodos(p.session.Todos) - hasQueue := p.promptQueue > 0 - hasPills := hasIncompleteTodos || hasQueue - - pillsAreaHeight := 0 - if hasPills { - pillsAreaHeight = pillHeightWithBorder + 1 // +1 for padding top - if p.pillsExpanded { - if p.focusedPillSection == PillSectionTodos && hasIncompleteTodos { - pillsAreaHeight += len(p.session.Todos) - } else if p.focusedPillSection == PillSectionQueue && hasQueue { - pillsAreaHeight += p.promptQueue - } - } - } - - if p.compact { - cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight-pillsAreaHeight)) - p.detailsWidth = width - DetailsPositioning - cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders)) - cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) - cmds = append(cmds, p.header.SetWidth(width-BorderWidth)) - } else { - cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight-pillsAreaHeight)) - cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) - cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight)) - } - cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight)) - } - return tea.Batch(cmds...) -} - -func (p *chatPage) newSession() tea.Cmd { - if p.session.ID == "" { - return nil - } - - p.session = session.Session{} - p.focusedPane = PanelTypeEditor - p.editor.Focus() - p.chat.Blur() - p.isCanceling = false - return tea.Batch( - util.CmdHandler(chat.SessionClearedMsg{}), - p.SetSize(p.width, p.height), - ) -} - -func (p *chatPage) setSession(sess session.Session) tea.Cmd { - if p.session.ID == sess.ID { - return nil - } - - var cmds []tea.Cmd - p.session = sess - - if p.hasInProgressTodo() { - cmds = append(cmds, p.todoSpinner.Tick) - } - - cmds = append(cmds, p.SetSize(p.width, p.height)) - cmds = append(cmds, p.chat.SetSession(sess)) - cmds = append(cmds, p.sidebar.SetSession(sess)) - cmds = append(cmds, p.header.SetSession(sess)) - cmds = append(cmds, p.editor.SetSession(sess)) - - return tea.Sequence(cmds...) -} - -func (p *chatPage) changeFocus() tea.Cmd { - if p.session.ID == "" { - return nil - } - - switch p.focusedPane { - case PanelTypeEditor: - p.focusedPane = PanelTypeChat - p.chat.Focus() - p.editor.Blur() - case PanelTypeChat: - p.focusedPane = PanelTypeEditor - p.editor.Focus() - p.chat.Blur() - } - return nil -} - -func (p *chatPage) togglePillsExpanded() tea.Cmd { - hasPills := hasIncompleteTodos(p.session.Todos) || p.promptQueue > 0 - if !hasPills { - return nil - } - p.pillsExpanded = !p.pillsExpanded - if p.pillsExpanded { - if hasIncompleteTodos(p.session.Todos) { - p.focusedPillSection = PillSectionTodos - } else { - p.focusedPillSection = PillSectionQueue - } - } - return p.SetSize(p.width, p.height) -} - -func (p *chatPage) switchPillSection(dir int) tea.Cmd { - if !p.pillsExpanded { - return nil - } - hasIncompleteTodos := hasIncompleteTodos(p.session.Todos) - hasQueue := p.promptQueue > 0 - - if dir < 0 && p.focusedPillSection == PillSectionQueue && hasIncompleteTodos { - p.focusedPillSection = PillSectionTodos - return p.SetSize(p.width, p.height) - } - if dir > 0 && p.focusedPillSection == PillSectionTodos && hasQueue { - p.focusedPillSection = PillSectionQueue - return p.SetSize(p.width, p.height) - } - return nil -} - -func (p *chatPage) cancel() tea.Cmd { - if p.isCanceling { - p.isCanceling = false - if p.app.AgentCoordinator != nil { - p.app.AgentCoordinator.Cancel(p.session.ID) - } - return nil - } - - if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 { - p.app.AgentCoordinator.ClearQueue(p.session.ID) - return nil - } - p.isCanceling = true - return cancelTimerCmd() -} - -func (p *chatPage) setShowDetails(show bool) { - p.showingDetails = show - p.header.SetDetailsOpen(p.showingDetails) - if !p.compact { - p.sidebar.SetCompactMode(false) - } -} - -func (p *chatPage) toggleDetails() { - if p.session.ID == "" || !p.compact { - return - } - p.setShowDetails(!p.showingDetails) -} - -func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd { - session := p.session - var cmds []tea.Cmd - if p.session.ID == "" { - // XXX: The second argument here is the session name, which we leave - // blank as it will be auto-generated. Ideally, we remove the need for - // that argument entirely. - newSession, err := p.app.Sessions.Create(context.Background(), "") - if err != nil { - return util.ReportError(err) - } - session = newSession - cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session))) - } - if p.app.AgentCoordinator == nil { - return util.ReportError(fmt.Errorf("coder agent is not initialized")) - } - cmds = append(cmds, p.chat.GoToBottom()) - cmds = append(cmds, func() tea.Msg { - _, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...) - if err != nil { - isCancelErr := errors.Is(err, context.Canceled) - isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied) - if isCancelErr || isPermissionErr { - return nil - } - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: err.Error(), - } - } - return nil - }) - return tea.Batch(cmds...) -} - -func (p *chatPage) Bindings() []key.Binding { - bindings := []key.Binding{ - p.keyMap.NewSession, - p.keyMap.AddAttachment, - } - if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() { - cancelBinding := p.keyMap.Cancel - if p.isCanceling { - cancelBinding = key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "press again to cancel"), - ) - } - bindings = append([]key.Binding{cancelBinding}, bindings...) - } - - switch p.focusedPane { - case PanelTypeChat: - bindings = append([]key.Binding{ - key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "focus editor"), - ), - }, bindings...) - bindings = append(bindings, p.chat.Bindings()...) - case PanelTypeEditor: - bindings = append([]key.Binding{ - key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "focus chat"), - ), - }, bindings...) - bindings = append(bindings, p.editor.Bindings()...) - case PanelTypeSplash: - bindings = append(bindings, p.splash.Bindings()...) - } - - return bindings -} - -func (p *chatPage) Help() help.KeyMap { - var shortList []key.Binding - var fullList [][]key.Binding - switch { - case p.isOnboarding: - switch { - case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2(): - shortList = append(shortList, - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "copy url & open signup"), - ), - key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "copy url"), - ), - ) - default: - shortList = append(shortList, - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "submit"), - ), - ) - } - shortList = append(shortList, - // Quit - key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - ) - // keep them the same - for _, v := range shortList { - fullList = append(fullList, []key.Binding{v}) - } - case p.isOnboarding && !p.splash.IsShowingAPIKey(): - shortList = append(shortList, - // Choose model - key.NewBinding( - key.WithKeys("up", "down"), - key.WithHelp("↑/↓", "choose"), - ), - // Accept selection - key.NewBinding( - key.WithKeys("enter", "ctrl+y"), - key.WithHelp("enter", "accept"), - ), - // Quit - key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - ) - // keep them the same - for _, v := range shortList { - fullList = append(fullList, []key.Binding{v}) - } - case p.isOnboarding && p.splash.IsShowingAPIKey(): - if p.splash.IsAPIKeyValid() { - shortList = append(shortList, - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "continue"), - ), - ) - } else { - shortList = append(shortList, - // Go back - key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "back"), - ), - ) - } - shortList = append(shortList, - // Quit - key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - ) - // keep them the same - for _, v := range shortList { - fullList = append(fullList, []key.Binding{v}) - } - case p.isProjectInit: - shortList = append(shortList, - key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - ) - // keep them the same - for _, v := range shortList { - fullList = append(fullList, []key.Binding{v}) - } - default: - if p.editor.IsCompletionsOpen() { - shortList = append(shortList, - key.NewBinding( - key.WithKeys("tab", "enter"), - key.WithHelp("tab/enter", "complete"), - ), - key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), - ), - key.NewBinding( - key.WithKeys("up", "down"), - key.WithHelp("↑/↓", "choose"), - ), - ) - for _, v := range shortList { - fullList = append(fullList, []key.Binding{v}) - } - return core.NewSimpleHelp(shortList, fullList) - } - if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() { - cancelBinding := key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), - ) - if p.isCanceling { - cancelBinding = key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "press again to cancel"), - ) - } - if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 { - cancelBinding = key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "clear queue"), - ) - } - shortList = append(shortList, cancelBinding) - fullList = append(fullList, - []key.Binding{ - cancelBinding, - }, - ) - } - globalBindings := []key.Binding{} - // we are in a session - if p.session.ID != "" { - var tabKey key.Binding - switch p.focusedPane { - case PanelTypeEditor: - tabKey = key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "focus chat"), - ) - case PanelTypeChat: - tabKey = key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "focus editor"), - ) - default: - tabKey = key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "focus chat"), - ) - } - shortList = append(shortList, tabKey) - globalBindings = append(globalBindings, tabKey) - - // Show left/right to switch sections when expanded and both exist - hasTodos := hasIncompleteTodos(p.session.Todos) - hasQueue := p.promptQueue > 0 - if p.pillsExpanded && hasTodos && hasQueue { - shortList = append(shortList, p.keyMap.PillLeft) - globalBindings = append(globalBindings, p.keyMap.PillLeft) - } - } - commandsBinding := key.NewBinding( - key.WithKeys("ctrl+p"), - key.WithHelp("ctrl+p", "commands"), - ) - if p.focusedPane == PanelTypeEditor && p.editor.IsEmpty() { - commandsBinding.SetHelp("/ or ctrl+p", "commands") - } - modelsBinding := key.NewBinding( - key.WithKeys("ctrl+m", "ctrl+l"), - key.WithHelp("ctrl+l", "models"), - ) - if p.keyboardEnhancements.Flags > 0 { - // non-zero flags mean we have at least key disambiguation - modelsBinding.SetHelp("ctrl+m", "models") - } - helpBinding := key.NewBinding( - key.WithKeys("ctrl+g"), - key.WithHelp("ctrl+g", "more"), - ) - globalBindings = append(globalBindings, commandsBinding, modelsBinding) - globalBindings = append(globalBindings, - key.NewBinding( - key.WithKeys("ctrl+s"), - key.WithHelp("ctrl+s", "sessions"), - ), - ) - if p.session.ID != "" { - globalBindings = append(globalBindings, - key.NewBinding( - key.WithKeys("ctrl+n"), - key.WithHelp("ctrl+n", "new sessions"), - )) - } - shortList = append(shortList, - // Commands - commandsBinding, - modelsBinding, - ) - fullList = append(fullList, globalBindings) - - switch p.focusedPane { - case PanelTypeChat: - shortList = append(shortList, - key.NewBinding( - key.WithKeys("up", "down"), - key.WithHelp("↑↓", "scroll"), - ), - messages.CopyKey, - ) - fullList = append(fullList, - []key.Binding{ - key.NewBinding( - key.WithKeys("up", "down"), - key.WithHelp("↑↓", "scroll"), - ), - key.NewBinding( - key.WithKeys("shift+up", "shift+down"), - key.WithHelp("shift+↑↓", "next/prev item"), - ), - key.NewBinding( - key.WithKeys("pgup", "b"), - key.WithHelp("b/pgup", "page up"), - ), - key.NewBinding( - key.WithKeys("pgdown", " ", "f"), - key.WithHelp("f/pgdn", "page down"), - ), - }, - []key.Binding{ - key.NewBinding( - key.WithKeys("u"), - key.WithHelp("u", "half page up"), - ), - key.NewBinding( - key.WithKeys("d"), - key.WithHelp("d", "half page down"), - ), - key.NewBinding( - key.WithKeys("g", "home"), - key.WithHelp("g", "home"), - ), - key.NewBinding( - key.WithKeys("G", "end"), - key.WithHelp("G", "end"), - ), - }, - []key.Binding{ - messages.CopyKey, - messages.ClearSelectionKey, - }, - ) - case PanelTypeEditor: - newLineBinding := key.NewBinding( - key.WithKeys("shift+enter", "ctrl+j"), - // "ctrl+j" is a common keybinding for newline in many editors. If - // the terminal supports "shift+enter", we substitute the help text - // to reflect that. - key.WithHelp("ctrl+j", "newline"), - ) - if p.keyboardEnhancements.Flags > 0 { - // Non-zero flags mean we have at least key disambiguation. - newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc) - } - shortList = append(shortList, newLineBinding) - fullList = append(fullList, - []key.Binding{ - newLineBinding, - key.NewBinding( - key.WithKeys("ctrl+f"), - key.WithHelp("ctrl+f", "add image"), - ), - key.NewBinding( - key.WithKeys("@"), - key.WithHelp("@", "mention file"), - ), - key.NewBinding( - key.WithKeys("ctrl+o"), - key.WithHelp("ctrl+o", "open editor"), - ), - }) - - if p.editor.HasAttachments() { - fullList = append(fullList, []key.Binding{ - key.NewBinding( - key.WithKeys("ctrl+r"), - key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), - ), - key.NewBinding( - key.WithKeys("ctrl+r", "r"), - key.WithHelp("ctrl+r+r", "delete all attachments"), - ), - key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel delete mode"), - ), - }) - } - } - shortList = append(shortList, - // Quit - key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - // Help - helpBinding, - ) - fullList = append(fullList, []key.Binding{ - key.NewBinding( - key.WithKeys("ctrl+g"), - key.WithHelp("ctrl+g", "less"), - ), - }) - } - - return core.NewSimpleHelp(shortList, fullList) -} - -func (p *chatPage) IsChatFocused() bool { - return p.focusedPane == PanelTypeChat -} - -// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds. -// Returns true if the mouse is over the chat area, false otherwise. -func (p *chatPage) isMouseOverChat(x, y int) bool { - // No session means no chat area - if p.session.ID == "" { - return false - } - - var chatX, chatY, chatWidth, chatHeight int - - if p.compact { - // In compact mode: chat area starts after header and spans full width - chatX = 0 - chatY = HeaderHeight - chatWidth = p.width - chatHeight = p.height - EditorHeight - HeaderHeight - } else { - // In non-compact mode: chat area spans from left edge to sidebar - chatX = 0 - chatY = 0 - chatWidth = p.width - SideBarWidth - chatHeight = p.height - EditorHeight - } - - // Check if mouse coordinates are within chat bounds - return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight -} - -func (p *chatPage) hasInProgressTodo() bool { - for _, todo := range p.session.Todos { - if todo.Status == session.TodoStatusInProgress { - return true - } - } - return false -} diff --git a/internal/tui/page/chat/keys.go b/internal/tui/page/chat/keys.go deleted file mode 100644 index f22ec2bb4915b3d30e72df7f6f867e88447f5b7b..0000000000000000000000000000000000000000 --- a/internal/tui/page/chat/keys.go +++ /dev/null @@ -1,53 +0,0 @@ -package chat - -import ( - "charm.land/bubbles/v2/key" -) - -type KeyMap struct { - NewSession key.Binding - AddAttachment key.Binding - Cancel key.Binding - Tab key.Binding - Details key.Binding - TogglePills key.Binding - PillLeft key.Binding - PillRight key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - NewSession: key.NewBinding( - key.WithKeys("ctrl+n"), - key.WithHelp("ctrl+n", "new session"), - ), - AddAttachment: key.NewBinding( - key.WithKeys("ctrl+f"), - key.WithHelp("ctrl+f", "add attachment"), - ), - Cancel: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "change focus"), - ), - Details: key.NewBinding( - key.WithKeys("ctrl+d"), - key.WithHelp("ctrl+d", "toggle details"), - ), - TogglePills: key.NewBinding( - key.WithKeys("ctrl+space"), - key.WithHelp("ctrl+space", "toggle tasks"), - ), - PillLeft: key.NewBinding( - key.WithKeys("left"), - key.WithHelp("←/→", "switch section"), - ), - PillRight: key.NewBinding( - key.WithKeys("right"), - key.WithHelp("←/→", "switch section"), - ), - } -} diff --git a/internal/tui/page/chat/pills.go b/internal/tui/page/chat/pills.go deleted file mode 100644 index 40a363626946907641ad25bb44a1e9c3df752945..0000000000000000000000000000000000000000 --- a/internal/tui/page/chat/pills.go +++ /dev/null @@ -1,125 +0,0 @@ -package chat - -import ( - "fmt" - "strings" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/components/chat/todos" - "github.com/charmbracelet/crush/internal/tui/styles" -) - -func hasIncompleteTodos(todos []session.Todo) bool { - for _, todo := range todos { - if todo.Status != session.TodoStatusCompleted { - return true - } - } - return false -} - -const ( - pillHeightWithBorder = 3 - maxTaskDisplayLength = 40 - maxQueueDisplayLength = 60 -) - -func queuePill(queue int, focused, pillsPanelFocused bool, t *styles.Theme) string { - if queue <= 0 { - return "" - } - triangles := styles.ForegroundGrad("▶▶▶▶▶▶▶▶▶", false, t.RedDark, t.Accent) - if queue < 10 { - triangles = triangles[:queue] - } - - content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue) - - style := t.S().Base.PaddingLeft(1).PaddingRight(1) - if !pillsPanelFocused || focused { - style = style.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.BgOverlay) - } else { - style = style.BorderStyle(lipgloss.HiddenBorder()) - } - return style.Render(content) -} - -func todoPill(todos []session.Todo, spinnerView string, focused, pillsPanelFocused bool, t *styles.Theme) string { - if !hasIncompleteTodos(todos) { - return "" - } - - completed := 0 - var currentTodo *session.Todo - for i := range todos { - switch todos[i].Status { - case session.TodoStatusCompleted: - completed++ - case session.TodoStatusInProgress: - if currentTodo == nil { - currentTodo = &todos[i] - } - } - } - - total := len(todos) - - label := "To-Do" - progress := t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("%d/%d", completed, total)) - - var content string - if pillsPanelFocused { - content = fmt.Sprintf("%s %s", label, progress) - } else if currentTodo != nil { - taskText := currentTodo.Content - if currentTodo.ActiveForm != "" { - taskText = currentTodo.ActiveForm - } - if len(taskText) > maxTaskDisplayLength { - taskText = taskText[:maxTaskDisplayLength-1] + "…" - } - task := t.S().Base.Foreground(t.FgSubtle).Render(taskText) - content = fmt.Sprintf("%s %s %s %s", spinnerView, label, progress, task) - } else { - content = fmt.Sprintf("%s %s", label, progress) - } - - style := t.S().Base.PaddingLeft(1).PaddingRight(1) - if !pillsPanelFocused || focused { - style = style.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.BgOverlay) - } else { - style = style.BorderStyle(lipgloss.HiddenBorder()) - } - return style.Render(content) -} - -func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Theme, width int) string { - return todos.FormatTodosList(sessionTodos, spinnerView, t, width) -} - -func queueList(queueItems []string, t *styles.Theme) string { - if len(queueItems) == 0 { - return "" - } - - var lines []string - for _, item := range queueItems { - text := item - if len(text) > maxQueueDisplayLength { - text = text[:maxQueueDisplayLength-1] + "…" - } - prefix := t.S().Base.Foreground(t.FgMuted).Render(" •") + " " - lines = append(lines, prefix+t.S().Base.Foreground(t.FgMuted).Render(text)) - } - - return strings.Join(lines, "\n") -} - -func sectionLine(availableWidth int, t *styles.Theme) string { - if availableWidth <= 0 { - return "" - } - line := strings.Repeat("─", availableWidth) - return t.S().Base.Foreground(t.Border).Render(line) -} diff --git a/internal/tui/page/page.go b/internal/tui/page/page.go deleted file mode 100644 index 482df5fd7b85706fb59a90e9ca5938de8fb729ea..0000000000000000000000000000000000000000 --- a/internal/tui/page/page.go +++ /dev/null @@ -1,8 +0,0 @@ -package page - -type PageID string - -// PageChangeMsg is used to change the current page -type PageChangeMsg struct { - ID PageID -} diff --git a/internal/tui/styles/charmtone.go b/internal/tui/styles/charmtone.go deleted file mode 100644 index 44508e5a24e68ea0507af0f2649ddc372711104d..0000000000000000000000000000000000000000 --- a/internal/tui/styles/charmtone.go +++ /dev/null @@ -1,83 +0,0 @@ -package styles - -import ( - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/exp/charmtone" -) - -func NewCharmtoneTheme() *Theme { - t := &Theme{ - Name: "charmtone", - IsDark: true, - - Primary: charmtone.Charple, - Secondary: charmtone.Dolly, - Tertiary: charmtone.Bok, - Accent: charmtone.Zest, - - // Backgrounds - BgBase: charmtone.Pepper, - BgBaseLighter: charmtone.BBQ, - BgSubtle: charmtone.Charcoal, - BgOverlay: charmtone.Iron, - - // Foregrounds - FgBase: charmtone.Ash, - FgMuted: charmtone.Squid, - FgHalfMuted: charmtone.Smoke, - FgSubtle: charmtone.Oyster, - FgSelected: charmtone.Salt, - - // Borders - Border: charmtone.Charcoal, - BorderFocus: charmtone.Charple, - - // Status - Success: charmtone.Guac, - Error: charmtone.Sriracha, - Warning: charmtone.Zest, - Info: charmtone.Malibu, - - // Colors - White: charmtone.Butter, - - BlueLight: charmtone.Sardine, - BlueDark: charmtone.Damson, - Blue: charmtone.Malibu, - - Yellow: charmtone.Mustard, - Citron: charmtone.Citron, - - Green: charmtone.Julep, - GreenDark: charmtone.Guac, - GreenLight: charmtone.Bok, - - Red: charmtone.Coral, - RedDark: charmtone.Sriracha, - RedLight: charmtone.Salmon, - Cherry: charmtone.Cherry, - } - - // Text selection. - t.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple) - - // LSP and MCP status. - t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●") - t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron) - t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral) - t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac) - - // Editor: Yolo Mode. - t.YoloIconFocused = lipgloss.NewStyle().Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ") - t.YoloIconBlurred = t.YoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid) - t.YoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::") - t.YoloDotsBlurred = t.YoloDotsFocused.Foreground(charmtone.Squid) - - // oAuth Chooser. - t.AuthBorderSelected = lipgloss.NewStyle().BorderForeground(charmtone.Guac) - t.AuthTextSelected = lipgloss.NewStyle().Foreground(charmtone.Julep) - t.AuthBorderUnselected = lipgloss.NewStyle().BorderForeground(charmtone.Iron) - t.AuthTextUnselected = lipgloss.NewStyle().Foreground(charmtone.Squid) - - return t -} diff --git a/internal/tui/styles/chroma.go b/internal/tui/styles/chroma.go deleted file mode 100644 index bd656336a8236f5f9aa57fc2d7b98a1e84d4c932..0000000000000000000000000000000000000000 --- a/internal/tui/styles/chroma.go +++ /dev/null @@ -1,79 +0,0 @@ -package styles - -import ( - "charm.land/glamour/v2/ansi" - "github.com/alecthomas/chroma/v2" -) - -func chromaStyle(style ansi.StylePrimitive) string { - var s string - - if style.Color != nil { - s = *style.Color - } - if style.BackgroundColor != nil { - if s != "" { - s += " " - } - s += "bg:" + *style.BackgroundColor - } - if style.Italic != nil && *style.Italic { - if s != "" { - s += " " - } - s += "italic" - } - if style.Bold != nil && *style.Bold { - if s != "" { - s += " " - } - s += "bold" - } - if style.Underline != nil && *style.Underline { - if s != "" { - s += " " - } - s += "underline" - } - - return s -} - -func GetChromaTheme() chroma.StyleEntries { - t := CurrentTheme() - rules := t.S().Markdown.CodeBlock - - return chroma.StyleEntries{ - chroma.Text: chromaStyle(rules.Chroma.Text), - chroma.Error: chromaStyle(rules.Chroma.Error), - chroma.Comment: chromaStyle(rules.Chroma.Comment), - chroma.CommentPreproc: chromaStyle(rules.Chroma.CommentPreproc), - chroma.Keyword: chromaStyle(rules.Chroma.Keyword), - chroma.KeywordReserved: chromaStyle(rules.Chroma.KeywordReserved), - chroma.KeywordNamespace: chromaStyle(rules.Chroma.KeywordNamespace), - chroma.KeywordType: chromaStyle(rules.Chroma.KeywordType), - chroma.Operator: chromaStyle(rules.Chroma.Operator), - chroma.Punctuation: chromaStyle(rules.Chroma.Punctuation), - chroma.Name: chromaStyle(rules.Chroma.Name), - chroma.NameBuiltin: chromaStyle(rules.Chroma.NameBuiltin), - chroma.NameTag: chromaStyle(rules.Chroma.NameTag), - chroma.NameAttribute: chromaStyle(rules.Chroma.NameAttribute), - chroma.NameClass: chromaStyle(rules.Chroma.NameClass), - chroma.NameConstant: chromaStyle(rules.Chroma.NameConstant), - chroma.NameDecorator: chromaStyle(rules.Chroma.NameDecorator), - chroma.NameException: chromaStyle(rules.Chroma.NameException), - chroma.NameFunction: chromaStyle(rules.Chroma.NameFunction), - chroma.NameOther: chromaStyle(rules.Chroma.NameOther), - chroma.Literal: chromaStyle(rules.Chroma.Literal), - chroma.LiteralNumber: chromaStyle(rules.Chroma.LiteralNumber), - chroma.LiteralDate: chromaStyle(rules.Chroma.LiteralDate), - chroma.LiteralString: chromaStyle(rules.Chroma.LiteralString), - chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape), - chroma.GenericDeleted: chromaStyle(rules.Chroma.GenericDeleted), - chroma.GenericEmph: chromaStyle(rules.Chroma.GenericEmph), - chroma.GenericInserted: chromaStyle(rules.Chroma.GenericInserted), - chroma.GenericStrong: chromaStyle(rules.Chroma.GenericStrong), - chroma.GenericSubheading: chromaStyle(rules.Chroma.GenericSubheading), - chroma.Background: chromaStyle(rules.Chroma.Background), - } -} diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go deleted file mode 100644 index 0db13358a2f9812293c18497b71ba138484b8f17..0000000000000000000000000000000000000000 --- a/internal/tui/styles/icons.go +++ /dev/null @@ -1,48 +0,0 @@ -package styles - -const ( - CheckIcon string = "✓" - ErrorIcon string = "×" - WarningIcon string = "⚠" - InfoIcon string = "ⓘ" - HintIcon string = "∵" - SpinnerIcon string = "..." - ArrowRightIcon string = "→" - CenterSpinnerIcon string = "⋯" - LoadingIcon string = "⟳" - ImageIcon string = "■" - TextIcon string = "☰" - ModelIcon string = "◇" - - // Tool call icons - ToolPending string = "●" - ToolSuccess string = "✓" - ToolError string = "×" - - BorderThin string = "│" - BorderThick string = "▌" - - // Todo icons - TodoCompletedIcon string = "✓" - TodoPendingIcon string = "•" -) - -var SelectionIgnoreIcons = []string{ - // CheckIcon, - // ErrorIcon, - // WarningIcon, - // InfoIcon, - // HintIcon, - // SpinnerIcon, - // LoadingIcon, - // DocumentIcon, - // ModelIcon, - // - // // Tool call icons - // ToolPending, - // ToolSuccess, - // ToolError, - - BorderThin, - BorderThick, -} diff --git a/internal/tui/styles/markdown.go b/internal/tui/styles/markdown.go deleted file mode 100644 index fd857703ee21912ee3ddc7d6112798317c34fa59..0000000000000000000000000000000000000000 --- a/internal/tui/styles/markdown.go +++ /dev/null @@ -1,205 +0,0 @@ -package styles - -import ( - "fmt" - "image/color" - - "charm.land/glamour/v2" - "charm.land/glamour/v2/ansi" -) - -// lipglossColorToHex converts a color.Color to hex string -func lipglossColorToHex(c color.Color) string { - r, g, b, _ := c.RGBA() - return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8) -} - -// Helper functions for style pointers -func boolPtr(b bool) *bool { return &b } -func stringPtr(s string) *string { return &s } -func uintPtr(u uint) *uint { return &u } - -// returns a glamour TermRenderer configured with the current theme -func GetMarkdownRenderer(width int) *glamour.TermRenderer { - t := CurrentTheme() - r, _ := glamour.NewTermRenderer( - glamour.WithStyles(t.S().Markdown), - glamour.WithWordWrap(width), - ) - return r -} - -// returns a glamour TermRenderer with no colors (plain text with structure) -func GetPlainMarkdownRenderer(width int) *glamour.TermRenderer { - r, _ := glamour.NewTermRenderer( - glamour.WithStyles(PlainMarkdownStyle()), - glamour.WithWordWrap(width), - ) - return r -} - -// PlainMarkdownStyle returns a glamour style config with no colors -func PlainMarkdownStyle() ansi.StyleConfig { - t := CurrentTheme() - bgColor := stringPtr(lipglossColorToHex(t.BgBaseLighter)) - fgColor := stringPtr(lipglossColorToHex(t.FgMuted)) - return ansi.StyleConfig{ - Document: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - BlockQuote: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: fgColor, - BackgroundColor: bgColor, - }, - Indent: uintPtr(1), - IndentToken: stringPtr("│ "), - }, - List: ansi.StyleList{ - LevelIndent: defaultListIndent, - }, - Heading: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - BlockSuffix: "\n", - Bold: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - H1: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Suffix: " ", - Bold: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - H2: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "## ", - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - H3: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "### ", - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - H4: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "#### ", - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - H5: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "##### ", - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - H6: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "###### ", - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - Strikethrough: ansi.StylePrimitive{ - CrossedOut: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - Emph: ansi.StylePrimitive{ - Italic: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - Strong: ansi.StylePrimitive{ - Bold: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - HorizontalRule: ansi.StylePrimitive{ - Format: "\n--------\n", - Color: fgColor, - BackgroundColor: bgColor, - }, - Item: ansi.StylePrimitive{ - BlockPrefix: "• ", - Color: fgColor, - BackgroundColor: bgColor, - }, - Enumeration: ansi.StylePrimitive{ - BlockPrefix: ". ", - Color: fgColor, - BackgroundColor: bgColor, - }, - Task: ansi.StyleTask{ - StylePrimitive: ansi.StylePrimitive{ - Color: fgColor, - BackgroundColor: bgColor, - }, - Ticked: "[✓] ", - Unticked: "[ ] ", - }, - Link: ansi.StylePrimitive{ - Underline: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - LinkText: ansi.StylePrimitive{ - Bold: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - Image: ansi.StylePrimitive{ - Underline: boolPtr(true), - Color: fgColor, - BackgroundColor: bgColor, - }, - ImageText: ansi.StylePrimitive{ - Format: "Image: {{.text}} →", - Color: fgColor, - BackgroundColor: bgColor, - }, - Code: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Suffix: " ", - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - CodeBlock: ansi.StyleCodeBlock{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: fgColor, - BackgroundColor: bgColor, - }, - Margin: uintPtr(defaultMargin), - }, - }, - Table: ansi.StyleTable{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: fgColor, - BackgroundColor: bgColor, - }, - }, - }, - DefinitionDescription: ansi.StylePrimitive{ - BlockPrefix: "\n ", - Color: fgColor, - BackgroundColor: bgColor, - }, - } -} diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go deleted file mode 100644 index b03603c57439f5f950f9860d3287b0f9d13742e5..0000000000000000000000000000000000000000 --- a/internal/tui/styles/theme.go +++ /dev/null @@ -1,709 +0,0 @@ -package styles - -import ( - "fmt" - "image/color" - "strings" - "sync" - - "charm.land/bubbles/v2/filepicker" - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/textarea" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/glamour/v2/ansi" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/exp/diffview" - "github.com/charmbracelet/x/exp/charmtone" - "github.com/lucasb-eyer/go-colorful" - "github.com/rivo/uniseg" -) - -const ( - defaultListIndent = 2 - defaultListLevelIndent = 4 - defaultMargin = 2 -) - -type Theme struct { - Name string - IsDark bool - - Primary color.Color - Secondary color.Color - Tertiary color.Color - Accent color.Color - - BgBase color.Color - BgBaseLighter color.Color - BgSubtle color.Color - BgOverlay color.Color - - FgBase color.Color - FgMuted color.Color - FgHalfMuted color.Color - FgSubtle color.Color - FgSelected color.Color - - Border color.Color - BorderFocus color.Color - - Success color.Color - Error color.Color - Warning color.Color - Info color.Color - - // Colors - // White - White color.Color - - // Blues - BlueLight color.Color - BlueDark color.Color - Blue color.Color - - // Yellows - Yellow color.Color - Citron color.Color - - // Greens - Green color.Color - GreenDark color.Color - GreenLight color.Color - - // Reds - Red color.Color - RedDark color.Color - RedLight color.Color - Cherry color.Color - - // Text selection. - TextSelection lipgloss.Style - - // LSP and MCP status indicators. - ItemOfflineIcon lipgloss.Style - ItemBusyIcon lipgloss.Style - ItemErrorIcon lipgloss.Style - ItemOnlineIcon lipgloss.Style - - // Editor: Yolo Mode. - YoloIconFocused lipgloss.Style - YoloIconBlurred lipgloss.Style - YoloDotsFocused lipgloss.Style - YoloDotsBlurred lipgloss.Style - - // oAuth Chooser. - AuthBorderSelected lipgloss.Style - AuthTextSelected lipgloss.Style - AuthBorderUnselected lipgloss.Style - AuthTextUnselected lipgloss.Style - - styles *Styles - stylesOnce sync.Once -} - -type Styles struct { - Base lipgloss.Style - SelectedBase lipgloss.Style - - Title lipgloss.Style - Subtitle lipgloss.Style - Text lipgloss.Style - TextSelected lipgloss.Style - Muted lipgloss.Style - Subtle lipgloss.Style - - Success lipgloss.Style - Error lipgloss.Style - Warning lipgloss.Style - Info lipgloss.Style - - // Markdown & Chroma - Markdown ansi.StyleConfig - - // Inputs - TextInput textinput.Styles - TextArea textarea.Styles - - // Help - Help help.Styles - - // Diff - Diff diffview.Style - - // FilePicker - FilePicker filepicker.Styles -} - -func (t *Theme) S() *Styles { - t.stylesOnce.Do(func() { - t.styles = t.buildStyles() - }) - return t.styles -} - -func (t *Theme) buildStyles() *Styles { - base := lipgloss.NewStyle(). - Foreground(t.FgBase) - return &Styles{ - Base: base, - - SelectedBase: base.Background(t.Primary), - - Title: base. - Foreground(t.Accent). - Bold(true), - - Subtitle: base. - Foreground(t.Secondary). - Bold(true), - - Text: base, - TextSelected: base.Background(t.Primary).Foreground(t.FgSelected), - - Muted: base.Foreground(t.FgMuted), - - Subtle: base.Foreground(t.FgSubtle), - - Success: base.Foreground(t.Success), - - Error: base.Foreground(t.Error), - - Warning: base.Foreground(t.Warning), - - Info: base.Foreground(t.Info), - - TextInput: textinput.Styles{ - Focused: textinput.StyleState{ - Text: base, - Placeholder: base.Foreground(t.FgSubtle), - Prompt: base.Foreground(t.Tertiary), - Suggestion: base.Foreground(t.FgSubtle), - }, - Blurred: textinput.StyleState{ - Text: base.Foreground(t.FgMuted), - Placeholder: base.Foreground(t.FgSubtle), - Prompt: base.Foreground(t.FgMuted), - Suggestion: base.Foreground(t.FgSubtle), - }, - Cursor: textinput.CursorStyle{ - Color: t.Secondary, - Shape: tea.CursorBlock, - Blink: true, - }, - }, - TextArea: textarea.Styles{ - Focused: textarea.StyleState{ - Base: base, - Text: base, - LineNumber: base.Foreground(t.FgSubtle), - CursorLine: base, - CursorLineNumber: base.Foreground(t.FgSubtle), - Placeholder: base.Foreground(t.FgSubtle), - Prompt: base.Foreground(t.Tertiary), - }, - Blurred: textarea.StyleState{ - Base: base, - Text: base.Foreground(t.FgMuted), - LineNumber: base.Foreground(t.FgMuted), - CursorLine: base, - CursorLineNumber: base.Foreground(t.FgMuted), - Placeholder: base.Foreground(t.FgSubtle), - Prompt: base.Foreground(t.FgMuted), - }, - Cursor: textarea.CursorStyle{ - Color: t.Secondary, - Shape: tea.CursorBlock, - Blink: true, - }, - }, - - Markdown: ansi.StyleConfig{ - Document: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - // BlockPrefix: "\n", - // BlockSuffix: "\n", - Color: stringPtr(charmtone.Smoke.Hex()), - }, - // Margin: uintPtr(defaultMargin), - }, - BlockQuote: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - Indent: uintPtr(1), - IndentToken: stringPtr("│ "), - }, - List: ansi.StyleList{ - LevelIndent: defaultListIndent, - }, - Heading: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - BlockSuffix: "\n", - Color: stringPtr(charmtone.Malibu.Hex()), - Bold: boolPtr(true), - }, - }, - H1: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Suffix: " ", - Color: stringPtr(charmtone.Zest.Hex()), - BackgroundColor: stringPtr(charmtone.Charple.Hex()), - Bold: boolPtr(true), - }, - }, - H2: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "## ", - }, - }, - H3: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "### ", - }, - }, - H4: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "#### ", - }, - }, - H5: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "##### ", - }, - }, - H6: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "###### ", - Color: stringPtr(charmtone.Guac.Hex()), - Bold: boolPtr(false), - }, - }, - Strikethrough: ansi.StylePrimitive{ - CrossedOut: boolPtr(true), - }, - Emph: ansi.StylePrimitive{ - Italic: boolPtr(true), - }, - Strong: ansi.StylePrimitive{ - Bold: boolPtr(true), - }, - HorizontalRule: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Charcoal.Hex()), - Format: "\n--------\n", - }, - Item: ansi.StylePrimitive{ - BlockPrefix: "• ", - }, - Enumeration: ansi.StylePrimitive{ - BlockPrefix: ". ", - }, - Task: ansi.StyleTask{ - StylePrimitive: ansi.StylePrimitive{}, - Ticked: "[✓] ", - Unticked: "[ ] ", - }, - Link: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Zinc.Hex()), - Underline: boolPtr(true), - }, - LinkText: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guac.Hex()), - Bold: boolPtr(true), - }, - Image: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Cheeky.Hex()), - Underline: boolPtr(true), - }, - ImageText: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Squid.Hex()), - Format: "Image: {{.text}} →", - }, - Code: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Suffix: " ", - Color: stringPtr(charmtone.Coral.Hex()), - BackgroundColor: stringPtr(charmtone.Charcoal.Hex()), - }, - }, - CodeBlock: ansi.StyleCodeBlock{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Charcoal.Hex()), - }, - Margin: uintPtr(defaultMargin), - }, - Chroma: &ansi.Chroma{ - Text: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Smoke.Hex()), - }, - Error: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Butter.Hex()), - BackgroundColor: stringPtr(charmtone.Sriracha.Hex()), - }, - Comment: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Oyster.Hex()), - }, - CommentPreproc: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Bengal.Hex()), - }, - Keyword: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Malibu.Hex()), - }, - KeywordReserved: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Pony.Hex()), - }, - KeywordNamespace: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Pony.Hex()), - }, - KeywordType: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guppy.Hex()), - }, - Operator: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Salmon.Hex()), - }, - Punctuation: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Zest.Hex()), - }, - Name: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Smoke.Hex()), - }, - NameBuiltin: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Cheeky.Hex()), - }, - NameTag: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Mauve.Hex()), - }, - NameAttribute: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Hazy.Hex()), - }, - NameClass: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Salt.Hex()), - Underline: boolPtr(true), - Bold: boolPtr(true), - }, - NameDecorator: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Citron.Hex()), - }, - NameFunction: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guac.Hex()), - }, - LiteralNumber: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Julep.Hex()), - }, - LiteralString: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Cumin.Hex()), - }, - LiteralStringEscape: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Bok.Hex()), - }, - GenericDeleted: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Coral.Hex()), - }, - GenericEmph: ansi.StylePrimitive{ - Italic: boolPtr(true), - }, - GenericInserted: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guac.Hex()), - }, - GenericStrong: ansi.StylePrimitive{ - Bold: boolPtr(true), - }, - GenericSubheading: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Squid.Hex()), - }, - Background: ansi.StylePrimitive{ - BackgroundColor: stringPtr(charmtone.Charcoal.Hex()), - }, - }, - }, - Table: ansi.StyleTable{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - }, - }, - DefinitionDescription: ansi.StylePrimitive{ - BlockPrefix: "\n ", - }, - }, - - Help: help.Styles{ - ShortKey: base.Foreground(t.FgMuted), - ShortDesc: base.Foreground(t.FgSubtle), - ShortSeparator: base.Foreground(t.Border), - Ellipsis: base.Foreground(t.Border), - FullKey: base.Foreground(t.FgMuted), - FullDesc: base.Foreground(t.FgSubtle), - FullSeparator: base.Foreground(t.Border), - }, - - Diff: diffview.Style{ - DividerLine: diffview.LineStyle{ - LineNumber: lipgloss.NewStyle(). - Foreground(t.FgHalfMuted). - Background(t.BgBaseLighter), - Code: lipgloss.NewStyle(). - Foreground(t.FgHalfMuted). - Background(t.BgBaseLighter), - }, - MissingLine: diffview.LineStyle{ - LineNumber: lipgloss.NewStyle(). - Background(t.BgBaseLighter), - Code: lipgloss.NewStyle(). - Background(t.BgBaseLighter), - }, - EqualLine: diffview.LineStyle{ - LineNumber: lipgloss.NewStyle(). - Foreground(t.FgMuted). - Background(t.BgBase), - Code: lipgloss.NewStyle(). - Foreground(t.FgMuted). - Background(t.BgBase), - }, - InsertLine: diffview.LineStyle{ - LineNumber: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#629657")). - Background(lipgloss.Color("#2b322a")), - Symbol: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#629657")). - Background(lipgloss.Color("#323931")), - Code: lipgloss.NewStyle(). - Background(lipgloss.Color("#323931")), - }, - DeleteLine: diffview.LineStyle{ - LineNumber: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#a45c59")). - Background(lipgloss.Color("#312929")), - Symbol: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#a45c59")). - Background(lipgloss.Color("#383030")), - Code: lipgloss.NewStyle(). - Background(lipgloss.Color("#383030")), - }, - }, - FilePicker: filepicker.Styles{ - DisabledCursor: base.Foreground(t.FgMuted), - Cursor: base.Foreground(t.FgBase), - Symlink: base.Foreground(t.FgSubtle), - Directory: base.Foreground(t.Primary), - File: base.Foreground(t.FgBase), - DisabledFile: base.Foreground(t.FgMuted), - DisabledSelected: base.Background(t.BgOverlay).Foreground(t.FgMuted), - Permission: base.Foreground(t.FgMuted), - Selected: base.Background(t.Primary).Foreground(t.FgBase), - FileSize: base.Foreground(t.FgMuted), - EmptyDirectory: base.Foreground(t.FgMuted).PaddingLeft(2).SetString("Empty directory"), - }, - } -} - -type Manager struct { - themes map[string]*Theme - current *Theme -} - -var ( - defaultManager *Manager - defaultManagerOnce sync.Once -) - -func initDefaultManager() *Manager { - defaultManagerOnce.Do(func() { - defaultManager = newManager() - }) - return defaultManager -} - -func SetDefaultManager(m *Manager) { - defaultManager = m -} - -func DefaultManager() *Manager { - return initDefaultManager() -} - -func CurrentTheme() *Theme { - return initDefaultManager().Current() -} - -func newManager() *Manager { - m := &Manager{ - themes: make(map[string]*Theme), - } - - t := NewCharmtoneTheme() // default theme - m.Register(t) - m.current = m.themes[t.Name] - - return m -} - -func (m *Manager) Register(theme *Theme) { - m.themes[theme.Name] = theme -} - -func (m *Manager) Current() *Theme { - return m.current -} - -func (m *Manager) SetTheme(name string) error { - if theme, ok := m.themes[name]; ok { - m.current = theme - return nil - } - return fmt.Errorf("theme %s not found", name) -} - -func (m *Manager) List() []string { - names := make([]string, 0, len(m.themes)) - for name := range m.themes { - names = append(names, name) - } - return names -} - -// ParseHex converts hex string to color -func ParseHex(hex string) color.Color { - var r, g, b uint8 - fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b) - return color.RGBA{R: r, G: g, B: b, A: 255} -} - -// Alpha returns a color with transparency -func Alpha(c color.Color, alpha uint8) color.Color { - r, g, b, _ := c.RGBA() - return color.RGBA{ - R: uint8(r >> 8), - G: uint8(g >> 8), - B: uint8(b >> 8), - A: alpha, - } -} - -// Darken makes a color darker by percentage (0-100) -func Darken(c color.Color, percent float64) color.Color { - r, g, b, a := c.RGBA() - factor := 1.0 - percent/100.0 - return color.RGBA{ - R: uint8(float64(r>>8) * factor), - G: uint8(float64(g>>8) * factor), - B: uint8(float64(b>>8) * factor), - A: uint8(a >> 8), - } -} - -// Lighten makes a color lighter by percentage (0-100) -func Lighten(c color.Color, percent float64) color.Color { - r, g, b, a := c.RGBA() - factor := percent / 100.0 - return color.RGBA{ - R: uint8(min(255, float64(r>>8)+255*factor)), - G: uint8(min(255, float64(g>>8)+255*factor)), - B: uint8(min(255, float64(b>>8)+255*factor)), - A: uint8(a >> 8), - } -} - -func ForegroundGrad(input string, bold bool, color1, color2 color.Color) []string { - if input == "" { - return []string{""} - } - t := CurrentTheme() - if len(input) == 1 { - style := t.S().Base.Foreground(color1) - if bold { - style.Bold(true) - } - return []string{style.Render(input)} - } - var clusters []string - gr := uniseg.NewGraphemes(input) - for gr.Next() { - clusters = append(clusters, string(gr.Runes())) - } - - ramp := blendColors(len(clusters), color1, color2) - for i, c := range ramp { - style := t.S().Base.Foreground(c) - if bold { - style.Bold(true) - } - clusters[i] = style.Render(clusters[i]) - } - return clusters -} - -// ApplyForegroundGrad renders a given string with a horizontal gradient -// foreground. -func ApplyForegroundGrad(input string, color1, color2 color.Color) string { - if input == "" { - return "" - } - var o strings.Builder - clusters := ForegroundGrad(input, false, color1, color2) - for _, c := range clusters { - fmt.Fprint(&o, c) - } - return o.String() -} - -// ApplyBoldForegroundGrad renders a given string with a horizontal gradient -// foreground. -func ApplyBoldForegroundGrad(input string, color1, color2 color.Color) string { - if input == "" { - return "" - } - var o strings.Builder - clusters := ForegroundGrad(input, true, color1, color2) - for _, c := range clusters { - fmt.Fprint(&o, c) - } - return o.String() -} - -// blendColors returns a slice of colors blended between the given keys. -// Blending is done in Hcl to stay in gamut. -func blendColors(size int, stops ...color.Color) []color.Color { - if len(stops) < 2 { - return nil - } - - stopsPrime := make([]colorful.Color, len(stops)) - for i, k := range stops { - stopsPrime[i], _ = colorful.MakeColor(k) - } - - numSegments := len(stopsPrime) - 1 - blended := make([]color.Color, 0, size) - - // Calculate how many colors each segment should have. - segmentSizes := make([]int, numSegments) - baseSize := size / numSegments - remainder := size % numSegments - - // Distribute the remainder across segments. - for i := range numSegments { - segmentSizes[i] = baseSize - if i < remainder { - segmentSizes[i]++ - } - } - - // Generate colors for each segment. - for i := range numSegments { - c1 := stopsPrime[i] - c2 := stopsPrime[i+1] - segmentSize := segmentSizes[i] - - for j := range segmentSize { - var t float64 - if segmentSize > 1 { - t = float64(j) / float64(segmentSize-1) - } - c := c1.BlendHcl(c2, t) - blended = append(blended, c) - } - } - - return blended -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go deleted file mode 100644 index 9a51a2497f09875d743e1051465dec7c1ac46e67..0000000000000000000000000000000000000000 --- a/internal/tui/tui.go +++ /dev/null @@ -1,712 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "math/rand" - "regexp" - "slices" - "strings" - "time" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/agent/tools/mcp" - "github.com/charmbracelet/crush/internal/app" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/event" - "github.com/charmbracelet/crush/internal/home" - "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/crush/internal/pubsub" - cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/chat/splash" - "github.com/charmbracelet/crush/internal/tui/components/completions" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/core/status" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions" - "github.com/charmbracelet/crush/internal/tui/page" - "github.com/charmbracelet/crush/internal/tui/page/chat" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - xstrings "github.com/charmbracelet/x/exp/strings" - "golang.org/x/mod/semver" - "golang.org/x/text/cases" - "golang.org/x/text/language" -) - -var lastMouseEvent time.Time - -func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg { - switch msg.(type) { - case tea.MouseWheelMsg, tea.MouseMotionMsg: - now := time.Now() - // trackpad is sending too many requests - if now.Sub(lastMouseEvent) < 15*time.Millisecond { - return nil - } - lastMouseEvent = now - } - return msg -} - -// appModel represents the main application model that manages pages, dialogs, and UI state. -type appModel struct { - wWidth, wHeight int // Window dimensions - width, height int - keyMap KeyMap - - currentPage page.PageID - previousPage page.PageID - pages map[page.PageID]util.Model - loadedPages map[page.PageID]bool - - // Status - status status.StatusCmp - showingFullHelp bool - - app *app.App - - dialog dialogs.DialogCmp - completions completions.Completions - isConfigured bool - - // Chat Page Specific - selectedSessionID string // The ID of the currently selected session - - // sendProgressBar instructs the TUI to send progress bar updates to the - // terminal. - sendProgressBar bool - - // QueryVersion instructs the TUI to query for the terminal version when it - // starts. - QueryVersion bool -} - -// Init initializes the application model and returns initial commands. -func (a appModel) Init() tea.Cmd { - item, ok := a.pages[a.currentPage] - if !ok { - return nil - } - - var cmds []tea.Cmd - cmd := item.Init() - cmds = append(cmds, cmd) - a.loadedPages[a.currentPage] = true - - cmd = a.status.Init() - cmds = append(cmds, cmd) - if a.QueryVersion { - cmds = append(cmds, tea.RequestTerminalVersion) - } - - return tea.Batch(cmds...) -} - -// Update handles incoming messages and updates the application state. -func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - var cmd tea.Cmd - a.isConfigured = config.HasInitialDataConfig() - - switch msg := msg.(type) { - case tea.EnvMsg: - // Is this Windows Terminal? - if !a.sendProgressBar { - a.sendProgressBar = slices.Contains(msg, "WT_SESSION") - } - case tea.TerminalVersionMsg: - if a.sendProgressBar { - return a, nil - } - termVersion := strings.ToLower(msg.Name) - switch { - case xstrings.ContainsAnyOf(termVersion, "ghostty", "rio"): - a.sendProgressBar = true - case strings.Contains(termVersion, "iterm2"): - // iTerm2 supports progress bars from version v3.6.6 - matches := regexp.MustCompile(`^iterm2 (\d+\.\d+\.\d+)$`).FindStringSubmatch(termVersion) - if len(matches) == 2 && semver.Compare("v"+matches[1], "v3.6.6") >= 0 { - a.sendProgressBar = true - } - } - return a, nil - case tea.KeyboardEnhancementsMsg: - // A non-zero value means we have key disambiguation support. - if msg.Flags > 0 { - a.keyMap.Models.SetHelp("ctrl+m", "models") - } - for id, page := range a.pages { - m, pageCmd := page.Update(msg) - a.pages[id] = m - - if pageCmd != nil { - cmds = append(cmds, pageCmd) - } - } - return a, tea.Batch(cmds...) - case tea.WindowSizeMsg: - a.wWidth, a.wHeight = msg.Width, msg.Height - a.completions.Update(msg) - return a, a.handleWindowResize(msg.Width, msg.Height) - - case pubsub.Event[mcp.Event]: - switch msg.Payload.Type { - case mcp.EventStateChanged: - return a, a.handleStateChanged(context.Background()) - case mcp.EventPromptsListChanged: - return a, handleMCPPromptsEvent(context.Background(), msg.Payload.Name) - case mcp.EventToolsListChanged: - return a, handleMCPToolsEvent(context.Background(), msg.Payload.Name) - } - - // Completions messages - case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, - completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg: - u, completionCmd := a.completions.Update(msg) - if model, ok := u.(completions.Completions); ok { - a.completions = model - } - - return a, completionCmd - - // Dialog messages - case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg: - u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{}) - a.completions = u.(completions.Completions) - u, dialogCmd := a.dialog.Update(msg) - a.dialog = u.(dialogs.DialogCmp) - return a, tea.Batch(completionCmd, dialogCmd) - case commands.ShowArgumentsDialogMsg: - var args []commands.Argument - for _, arg := range msg.ArgNames { - args = append(args, commands.Argument{ - Name: arg, - Title: cases.Title(language.English).String(arg), - Required: true, - }) - } - return a, util.CmdHandler( - dialogs.OpenDialogMsg{ - Model: commands.NewCommandArgumentsDialog( - msg.CommandID, - msg.CommandID, - msg.CommandID, - msg.Description, - args, - msg.OnSubmit, - ), - }, - ) - case commands.ShowMCPPromptArgumentsDialogMsg: - args := make([]commands.Argument, 0, len(msg.Prompt.Arguments)) - for _, arg := range msg.Prompt.Arguments { - args = append(args, commands.Argument(*arg)) - } - dialog := commands.NewCommandArgumentsDialog( - msg.Prompt.Name, - msg.Prompt.Title, - msg.Prompt.Name, - msg.Prompt.Description, - args, - msg.OnSubmit, - ) - return a, util.CmdHandler( - dialogs.OpenDialogMsg{ - Model: dialog, - }, - ) - // Page change messages - case page.PageChangeMsg: - return a, a.moveToPage(msg.ID) - - // Status Messages - case util.InfoMsg, util.ClearStatusMsg: - s, statusCmd := a.status.Update(msg) - a.status = s.(status.StatusCmp) - cmds = append(cmds, statusCmd) - return a, tea.Batch(cmds...) - - // Session - case cmpChat.SessionSelectedMsg: - a.selectedSessionID = msg.ID - case cmpChat.SessionClearedMsg: - a.selectedSessionID = "" - // Commands - case commands.SwitchSessionsMsg: - return a, func() tea.Msg { - allSessions, _ := a.app.Sessions.List(context.Background()) - return dialogs.OpenDialogMsg{ - Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID), - } - } - - case commands.SwitchModelMsg: - return a, util.CmdHandler( - dialogs.OpenDialogMsg{ - Model: models.NewModelDialogCmp(), - }, - ) - // Compact - case commands.CompactMsg: - return a, func() tea.Msg { - err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID) - if err != nil { - return util.ReportError(err)() - } - return nil - } - case commands.QuitMsg: - return a, util.CmdHandler(dialogs.OpenDialogMsg{ - Model: quit.NewQuitDialog(), - }) - case commands.ToggleYoloModeMsg: - a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests()) - case commands.ToggleHelpMsg: - a.status.ToggleFullHelp() - a.showingFullHelp = !a.showingFullHelp - return a, a.handleWindowResize(a.wWidth, a.wHeight) - // Model Switch - case models.ModelSelectedMsg: - if a.app.AgentCoordinator.IsBusy() { - return a, util.ReportWarn("Agent is busy, please wait...") - } - - cfg := config.Get() - if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil { - return a, util.ReportError(err) - } - - go a.app.UpdateAgentModel(context.TODO()) - - modelTypeName := "large" - if msg.ModelType == config.SelectedModelTypeSmall { - modelTypeName = "small" - } - return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model)) - - // File Picker - case commands.OpenFilePickerMsg: - event.FilePickerOpened() - - if a.dialog.ActiveDialogID() == filepicker.FilePickerID { - // If the commands dialog is already open, close it - return a, util.CmdHandler(dialogs.CloseDialogMsg{}) - } - return a, util.CmdHandler(dialogs.OpenDialogMsg{ - Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()), - }) - // Permissions - case pubsub.Event[permission.PermissionNotification]: - item, ok := a.pages[a.currentPage] - if !ok { - return a, nil - } - - // Forward to view. - updated, itemCmd := item.Update(msg) - a.pages[a.currentPage] = updated - - return a, itemCmd - case pubsub.Event[permission.PermissionRequest]: - return a, util.CmdHandler(dialogs.OpenDialogMsg{ - Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{ - DiffMode: config.Get().Options.TUI.DiffMode, - }), - }) - case permissions.PermissionResponseMsg: - switch msg.Action { - case permissions.PermissionAllow: - a.app.Permissions.Grant(msg.Permission) - case permissions.PermissionAllowForSession: - a.app.Permissions.GrantPersistent(msg.Permission) - case permissions.PermissionDeny: - a.app.Permissions.Deny(msg.Permission) - } - return a, nil - case splash.OnboardingCompleteMsg: - item, ok := a.pages[a.currentPage] - if !ok { - return a, nil - } - - a.isConfigured = config.HasInitialDataConfig() - updated, pageCmd := item.Update(msg) - a.pages[a.currentPage] = updated - - cmds = append(cmds, pageCmd) - return a, tea.Batch(cmds...) - - case tea.KeyPressMsg: - return a, a.handleKeyPressMsg(msg) - - case tea.MouseWheelMsg: - if a.dialog.HasDialogs() { - u, dialogCmd := a.dialog.Update(msg) - a.dialog = u.(dialogs.DialogCmp) - cmds = append(cmds, dialogCmd) - } else { - item, ok := a.pages[a.currentPage] - if !ok { - return a, nil - } - - updated, pageCmd := item.Update(msg) - a.pages[a.currentPage] = updated - - cmds = append(cmds, pageCmd) - } - return a, tea.Batch(cmds...) - case tea.PasteMsg: - if a.dialog.HasDialogs() { - u, dialogCmd := a.dialog.Update(msg) - if model, ok := u.(dialogs.DialogCmp); ok { - a.dialog = model - } - - cmds = append(cmds, dialogCmd) - } else { - item, ok := a.pages[a.currentPage] - if !ok { - return a, nil - } - - updated, pageCmd := item.Update(msg) - a.pages[a.currentPage] = updated - - cmds = append(cmds, pageCmd) - } - return a, tea.Batch(cmds...) - // Update Available - case app.UpdateAvailableMsg: - // Show update notification in status bar - statusMsg := fmt.Sprintf("Crush update available: v%s → v%s.", msg.CurrentVersion, msg.LatestVersion) - if msg.IsDevelopment { - statusMsg = fmt.Sprintf("This is a development version of Crush. The latest version is v%s.", msg.LatestVersion) - } - s, statusCmd := a.status.Update(util.InfoMsg{ - Type: util.InfoTypeUpdate, - Msg: statusMsg, - TTL: 10 * time.Second, - }) - a.status = s.(status.StatusCmp) - return a, statusCmd - } - s, _ := a.status.Update(msg) - a.status = s.(status.StatusCmp) - - item, ok := a.pages[a.currentPage] - if !ok { - return a, nil - } - - updated, cmd := item.Update(msg) - a.pages[a.currentPage] = updated - - if a.dialog.HasDialogs() { - u, dialogCmd := a.dialog.Update(msg) - if model, ok := u.(dialogs.DialogCmp); ok { - a.dialog = model - } - - cmds = append(cmds, dialogCmd) - } - cmds = append(cmds, cmd) - return a, tea.Batch(cmds...) -} - -// handleWindowResize processes window resize events and updates all components. -func (a *appModel) handleWindowResize(width, height int) tea.Cmd { - var cmds []tea.Cmd - - // TODO: clean up these magic numbers. - if a.showingFullHelp { - height -= 5 - } else { - height -= 2 - } - - a.width, a.height = width, height - // Update status bar - s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height}) - if model, ok := s.(status.StatusCmp); ok { - a.status = model - } - cmds = append(cmds, cmd) - - // Update the current view. - for p, page := range a.pages { - updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height}) - a.pages[p] = updated - - cmds = append(cmds, pageCmd) - } - - // Update the dialogs - dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height}) - if model, ok := dialog.(dialogs.DialogCmp); ok { - a.dialog = model - } - - cmds = append(cmds, cmd) - - return tea.Batch(cmds...) -} - -// handleKeyPressMsg processes keyboard input and routes to appropriate handlers. -func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { - // Check this first as the user should be able to quit no matter what. - if key.Matches(msg, a.keyMap.Quit) { - if a.dialog.ActiveDialogID() == quit.QuitDialogID { - return tea.Quit - } - return util.CmdHandler(dialogs.OpenDialogMsg{ - Model: quit.NewQuitDialog(), - }) - } - - if a.completions.Open() { - // completions - keyMap := a.completions.KeyMap() - switch { - case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down), - key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel), - key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert): - u, cmd := a.completions.Update(msg) - a.completions = u.(completions.Completions) - return cmd - } - } - if a.dialog.HasDialogs() { - u, dialogCmd := a.dialog.Update(msg) - a.dialog = u.(dialogs.DialogCmp) - return dialogCmd - } - switch { - // help - case key.Matches(msg, a.keyMap.Help): - a.status.ToggleFullHelp() - a.showingFullHelp = !a.showingFullHelp - return a.handleWindowResize(a.wWidth, a.wHeight) - // dialogs - case key.Matches(msg, a.keyMap.Commands): - // if the app is not configured show no commands - if !a.isConfigured { - return nil - } - if a.dialog.ActiveDialogID() == commands.CommandsDialogID { - return util.CmdHandler(dialogs.CloseDialogMsg{}) - } - if a.dialog.HasDialogs() { - return nil - } - return util.CmdHandler(dialogs.OpenDialogMsg{ - Model: commands.NewCommandDialog(a.selectedSessionID), - }) - case key.Matches(msg, a.keyMap.Models): - // if the app is not configured show no models - if !a.isConfigured { - return nil - } - if a.dialog.ActiveDialogID() == models.ModelsDialogID { - return util.CmdHandler(dialogs.CloseDialogMsg{}) - } - if a.dialog.HasDialogs() { - return nil - } - return util.CmdHandler(dialogs.OpenDialogMsg{ - Model: models.NewModelDialogCmp(), - }) - case key.Matches(msg, a.keyMap.Sessions): - // if the app is not configured show no sessions - if !a.isConfigured { - return nil - } - if a.dialog.ActiveDialogID() == sessions.SessionsDialogID { - return util.CmdHandler(dialogs.CloseDialogMsg{}) - } - if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID { - return nil - } - var cmds []tea.Cmd - cmds = append(cmds, - func() tea.Msg { - allSessions, _ := a.app.Sessions.List(context.Background()) - return dialogs.OpenDialogMsg{ - Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID), - } - }, - ) - return tea.Sequence(cmds...) - case key.Matches(msg, a.keyMap.Suspend): - if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() { - return util.ReportWarn("Agent is busy, please wait...") - } - return tea.Suspend - default: - item, ok := a.pages[a.currentPage] - if !ok { - return nil - } - - updated, cmd := item.Update(msg) - a.pages[a.currentPage] = updated - return cmd - } -} - -// moveToPage handles navigation between different pages in the application. -func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { - if a.app.AgentCoordinator.IsBusy() { - // TODO: maybe remove this : For now we don't move to any page if the agent is busy - return util.ReportWarn("Agent is busy, please wait...") - } - - var cmds []tea.Cmd - if _, ok := a.loadedPages[pageID]; !ok { - cmd := a.pages[pageID].Init() - cmds = append(cmds, cmd) - a.loadedPages[pageID] = true - } - a.previousPage = a.currentPage - a.currentPage = pageID - if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok { - cmd := sizable.SetSize(a.width, a.height) - cmds = append(cmds, cmd) - } - - return tea.Batch(cmds...) -} - -// View renders the complete application interface including pages, dialogs, and overlays. -func (a *appModel) View() tea.View { - var view tea.View - t := styles.CurrentTheme() - view.AltScreen = true - view.MouseMode = tea.MouseModeCellMotion - view.BackgroundColor = t.BgBase - view.WindowTitle = "crush " + home.Short(config.Get().WorkingDir()) - if a.wWidth < 25 || a.wHeight < 15 { - view.Content = t.S().Base.Width(a.wWidth).Height(a.wHeight). - Align(lipgloss.Center, lipgloss.Center). - Render(t.S().Base. - Padding(1, 4). - Foreground(t.White). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(t.Primary). - Render("Window too small!"), - ) - return view - } - - page := a.pages[a.currentPage] - if withHelp, ok := page.(core.KeyMapHelp); ok { - a.status.SetKeyMap(withHelp.Help()) - } - pageView := page.View() - components := []string{ - pageView, - } - components = append(components, a.status.View()) - - appView := lipgloss.JoinVertical(lipgloss.Top, components...) - layers := []*lipgloss.Layer{ - lipgloss.NewLayer(appView), - } - if a.dialog.HasDialogs() { - layers = append( - layers, - a.dialog.GetLayers()..., - ) - } - - var cursor *tea.Cursor - if v, ok := page.(util.Cursor); ok { - cursor = v.Cursor() - // Hide the cursor if it's positioned outside the textarea - statusHeight := a.height - strings.Count(pageView, "\n") + 1 - if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding - cursor = nil - } - } - activeView := a.dialog.ActiveModel() - if activeView != nil { - cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor - if v, ok := activeView.(util.Cursor); ok { - cursor = v.Cursor() - } - } - - if a.completions.Open() && cursor != nil { - cmp := a.completions.View() - x, y := a.completions.Position() - layers = append( - layers, - lipgloss.NewLayer(cmp).X(x).Y(y), - ) - } - - comp := lipgloss.NewCompositor(layers...) - view.Content = comp.Render() - view.Cursor = cursor - - if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() { - // HACK: use a random percentage to prevent ghostty from hiding it - // after a timeout. - view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100)) - } - return view -} - -func (a *appModel) handleStateChanged(ctx context.Context) tea.Cmd { - return func() tea.Msg { - a.app.UpdateAgentModel(ctx) - return nil - } -} - -func handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd { - return func() tea.Msg { - mcp.RefreshPrompts(ctx, name) - return nil - } -} - -func handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd { - return func() tea.Msg { - mcp.RefreshTools(ctx, name) - return nil - } -} - -// New creates and initializes a new TUI application model. -func New(app *app.App) *appModel { - chatPage := chat.New(app) - keyMap := DefaultKeyMap() - keyMap.pageBindings = chatPage.Bindings() - - model := &appModel{ - currentPage: chat.ChatPageID, - app: app, - status: status.NewStatusCmp(), - loadedPages: make(map[page.PageID]bool), - keyMap: keyMap, - - pages: map[page.PageID]util.Model{ - chat.ChatPageID: chatPage, - }, - - dialog: dialogs.NewDialogCmp(), - completions: completions.New(), - } - - return model -} diff --git a/internal/tui/util/shell.go b/internal/tui/util/shell.go deleted file mode 100644 index 7bf30e2640e79a80291077faa5134a9eea28a87b..0000000000000000000000000000000000000000 --- a/internal/tui/util/shell.go +++ /dev/null @@ -1,15 +0,0 @@ -package util - -import ( - "context" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/uiutil" -) - -// ExecShell parses a shell command string and executes it with exec.Command. -// Uses shell.Fields for proper handling of shell syntax like quotes and -// arguments while preserving TTY handling for terminal editors. -func ExecShell(ctx context.Context, cmdStr string, callback tea.ExecCallback) tea.Cmd { - return uiutil.ExecShell(ctx, cmdStr, callback) -} diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go deleted file mode 100644 index 5df57c11cc4491b25a048c7437057408f1e9c30f..0000000000000000000000000000000000000000 --- a/internal/tui/util/util.go +++ /dev/null @@ -1,45 +0,0 @@ -package util - -import ( - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/uiutil" -) - -type Cursor = uiutil.Cursor - -type Model interface { - Init() tea.Cmd - Update(tea.Msg) (Model, tea.Cmd) - View() string -} - -func CmdHandler(msg tea.Msg) tea.Cmd { - return uiutil.CmdHandler(msg) -} - -func ReportError(err error) tea.Cmd { - return uiutil.ReportError(err) -} - -type InfoType = uiutil.InfoType - -const ( - InfoTypeInfo = uiutil.InfoTypeInfo - InfoTypeSuccess = uiutil.InfoTypeSuccess - InfoTypeWarn = uiutil.InfoTypeWarn - InfoTypeError = uiutil.InfoTypeError - InfoTypeUpdate = uiutil.InfoTypeUpdate -) - -func ReportInfo(info string) tea.Cmd { - return uiutil.ReportInfo(info) -} - -func ReportWarn(warn string) tea.Cmd { - return uiutil.ReportWarn(warn) -} - -type ( - InfoMsg = uiutil.InfoMsg - ClearStatusMsg = uiutil.ClearStatusMsg -) diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 0c811b0384b0b1bf24b227b6500e8c9a21726d21..6e7c632474389aa5455295e4132818941bc18244 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -10,7 +10,7 @@ import ( "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/styles" - "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/crush/internal/ui/util" uv "github.com/charmbracelet/ultraviolet" ) @@ -95,6 +95,6 @@ func CopyToClipboardWithCallback(text, successMessage string, callback tea.Cmd) return nil }, callback, - uiutil.ReportInfo(successMessage), + util.ReportInfo(successMessage), ) } diff --git a/internal/ui/common/diff.go b/internal/ui/common/diff.go index 8007cebce93a0d0833be779eb11cbb703bc8c1d6..4fdbb3e4c48f23caccd715066d9087fea8654202 100644 --- a/internal/ui/common/diff.go +++ b/internal/ui/common/diff.go @@ -2,7 +2,7 @@ package common import ( "github.com/alecthomas/chroma/v2" - "github.com/charmbracelet/crush/internal/tui/exp/diffview" + "github.com/charmbracelet/crush/internal/ui/diffview" "github.com/charmbracelet/crush/internal/ui/styles" ) diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 7c11cbd91b202cfc16e1988027f9eed657368620..2776d886d49d979fd7673fb830dfa9d9a11f9006 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -15,7 +15,7 @@ import ( "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" - "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/crush/internal/ui/util" ) // ActionClose is a message to close the current dialog. @@ -131,22 +131,22 @@ func (a ActionFilePickerSelected) Cmd() tea.Cmd { return func() tea.Msg { isFileLarge, err := common.IsFileTooBig(path, common.MaxAttachmentSize) if err != nil { - return uiutil.InfoMsg{ - Type: uiutil.InfoTypeError, + return util.InfoMsg{ + Type: util.InfoTypeError, Msg: fmt.Sprintf("unable to read the image: %v", err), } } if isFileLarge { - return uiutil.InfoMsg{ - Type: uiutil.InfoTypeError, + return util.InfoMsg{ + Type: util.InfoTypeError, Msg: "file too large, max 5MB", } } content, err := os.ReadFile(path) if err != nil { - return uiutil.InfoMsg{ - Type: uiutil.InfoTypeError, + return util.InfoMsg{ + Type: util.InfoTypeError, Msg: fmt.Sprintf("unable to read the image: %v", err), } } diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index cb00477d3d5fb0f4aecb9167aa300980862e9132..6e9d26f0c5badc0d96bb6c6212ae3b69856b9e06 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -14,7 +14,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/styles" - "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/crush/internal/ui/util" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/exp/charmtone" ) @@ -316,7 +316,7 @@ func (m *APIKeyInput) saveKeyAndContinue() Action { err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value()) if err != nil { - return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))} + return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))} } return ActionSelectModel{ diff --git a/internal/ui/dialog/arguments.go b/internal/ui/dialog/arguments.go index 172c44eba0e015ee5562507fe92254cb047d4632..96eff11940841e2377e85fafeab9850fb844f139 100644 --- a/internal/ui/dialog/arguments.go +++ b/internal/ui/dialog/arguments.go @@ -15,7 +15,7 @@ import ( "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/ui/common" - "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/crush/internal/ui/util" uv "github.com/charmbracelet/ultraviolet" ) @@ -202,7 +202,7 @@ func (a *Arguments) HandleMsg(msg tea.Msg) Action { for i, arg := range a.arguments { args[arg.ID] = a.inputs[i].Value() if arg.Required && strings.TrimSpace(a.inputs[i].Value()) == "" { - warning = uiutil.ReportWarn("Required argument '" + arg.Title + "' is missing.") + warning = util.ReportWarn("Required argument '" + arg.Title + "' is missing.") break } } diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 44ff42a23c5eb722e4baa764346f631292799b30..2f729e19995790fc1bb57fbea4b80191195df8da 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -13,7 +13,7 @@ import ( "charm.land/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/common" - "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/crush/internal/ui/util" uv "github.com/charmbracelet/ultraviolet" ) @@ -207,7 +207,7 @@ func (m *Models) HandleMsg(msg tea.Msg) Action { m.modelType = ModelTypeLarge } if err := m.setProviderItems(); err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } default: var cmd tea.Cmd diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index 6fbb039255144ad14b15a39f34942e504dea3f2c..93d5fe052db11d036d29d7790810807d5630bb57 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -14,7 +14,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/oauth" "github.com/charmbracelet/crush/internal/ui/common" - "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/crush/internal/ui/util" uv "github.com/charmbracelet/ultraviolet" "github.com/pkg/browser" ) @@ -173,7 +173,7 @@ func (m *OAuth) HandleMsg(msg tea.Msg) Action { case ActionOAuthErrored: m.State = OAuthStateError - cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error)) + cmd := tea.Batch(m.oAuthProvider.stopPolling, util.ReportError(msg.Error)) return ActionCmd{cmd} } return nil @@ -352,7 +352,7 @@ func (d *OAuth) copyCode() tea.Cmd { } return tea.Sequence( tea.SetClipboard(d.userCode), - uiutil.ReportInfo("Code copied to clipboard"), + util.ReportInfo("Code copied to clipboard"), ) } @@ -368,7 +368,7 @@ func (d *OAuth) copyCodeAndOpenURL() tea.Cmd { } return nil }, - uiutil.ReportInfo("Code copied and URL opened"), + util.ReportInfo("Code copied and URL opened"), ) } @@ -377,7 +377,7 @@ func (m *OAuth) saveKeyAndContinue() Action { err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token) if err != nil { - return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))} + return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))} } return ActionSelectModel{ diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 227e060e6c6483644b4ad18bef00153bd4f6ca5f..cfb0f30623c383b775c3a960134057e6c79ce9b8 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -12,7 +12,7 @@ import ( "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" - "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/crush/internal/ui/util" uv "github.com/charmbracelet/ultraviolet" ) @@ -182,7 +182,7 @@ func (s *Session) HandleMsg(msg tea.Msg) Action { s.list.SetItems(sessionItems(s.com.Styles, sessionsModeUpdating, s.sessions...)...) case key.Matches(msg, s.keyMap.Delete): if s.isCurrentSessionBusy() { - return ActionCmd{uiutil.ReportWarn("Agent is busy, please wait...")} + return ActionCmd{util.ReportWarn("Agent is busy, please wait...")} } s.sessionsMode = sessionsModeDeleting s.list.SetItems(sessionItems(s.com.Styles, sessionsModeDeleting, s.sessions...)...) @@ -353,7 +353,7 @@ func (s *Session) deleteSessionCmd(id string) tea.Cmd { return func() tea.Msg { err := s.com.App.Sessions.Delete(context.TODO(), id) if err != nil { - return uiutil.NewErrorMsg(err) + return util.NewErrorMsg(err) } return nil } @@ -389,7 +389,7 @@ func (s *Session) updateSessionCmd(session session.Session) tea.Cmd { return func() tea.Msg { _, err := s.com.App.Sessions.Save(context.TODO(), session) if err != nil { - return uiutil.NewErrorMsg(err) + return util.NewErrorMsg(err) } return nil } diff --git a/internal/tui/exp/diffview/Taskfile.yaml b/internal/ui/diffview/Taskfile.yaml similarity index 100% rename from internal/tui/exp/diffview/Taskfile.yaml rename to internal/ui/diffview/Taskfile.yaml diff --git a/internal/tui/exp/diffview/chroma.go b/internal/ui/diffview/chroma.go similarity index 100% rename from internal/tui/exp/diffview/chroma.go rename to internal/ui/diffview/chroma.go diff --git a/internal/tui/exp/diffview/diffview.go b/internal/ui/diffview/diffview.go similarity index 100% rename from internal/tui/exp/diffview/diffview.go rename to internal/ui/diffview/diffview.go diff --git a/internal/tui/exp/diffview/diffview_test.go b/internal/ui/diffview/diffview_test.go similarity index 99% rename from internal/tui/exp/diffview/diffview_test.go rename to internal/ui/diffview/diffview_test.go index de6eb301c9261fdbf2175ba061528b71e162ea40..266b26372dc7c519c353228590a03cbbc9e65e24 100644 --- a/internal/tui/exp/diffview/diffview_test.go +++ b/internal/ui/diffview/diffview_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/alecthomas/chroma/v2/styles" - "github.com/charmbracelet/crush/internal/tui/exp/diffview" + "github.com/charmbracelet/crush/internal/ui/diffview" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" ) diff --git a/internal/tui/exp/diffview/split.go b/internal/ui/diffview/split.go similarity index 100% rename from internal/tui/exp/diffview/split.go rename to internal/ui/diffview/split.go diff --git a/internal/tui/exp/diffview/style.go b/internal/ui/diffview/style.go similarity index 100% rename from internal/tui/exp/diffview/style.go rename to internal/ui/diffview/style.go diff --git a/internal/tui/exp/diffview/testdata/TestDefault.after b/internal/ui/diffview/testdata/TestDefault.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDefault.after rename to internal/ui/diffview/testdata/TestDefault.after diff --git a/internal/tui/exp/diffview/testdata/TestDefault.before b/internal/ui/diffview/testdata/TestDefault.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDefault.before rename to internal/ui/diffview/testdata/TestDefault.before diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/Default/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/Default/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden b/internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden rename to internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden b/internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden rename to internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewTabs/Split.golden b/internal/ui/diffview/testdata/TestDiffViewTabs/Split.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewTabs/Split.golden rename to internal/ui/diffview/testdata/TestDiffViewTabs/Split.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewTabs/Unified.golden b/internal/ui/diffview/testdata/TestDiffViewTabs/Unified.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewTabs/Unified.golden rename to internal/ui/diffview/testdata/TestDiffViewTabs/Unified.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestLineBreakIssue.after b/internal/ui/diffview/testdata/TestLineBreakIssue.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestLineBreakIssue.after rename to internal/ui/diffview/testdata/TestLineBreakIssue.after diff --git a/internal/tui/exp/diffview/testdata/TestLineBreakIssue.before b/internal/ui/diffview/testdata/TestLineBreakIssue.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestLineBreakIssue.before rename to internal/ui/diffview/testdata/TestLineBreakIssue.before diff --git a/internal/tui/exp/diffview/testdata/TestMultipleHunks.after b/internal/ui/diffview/testdata/TestMultipleHunks.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestMultipleHunks.after rename to internal/ui/diffview/testdata/TestMultipleHunks.after diff --git a/internal/tui/exp/diffview/testdata/TestMultipleHunks.before b/internal/ui/diffview/testdata/TestMultipleHunks.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestMultipleHunks.before rename to internal/ui/diffview/testdata/TestMultipleHunks.before diff --git a/internal/tui/exp/diffview/testdata/TestNarrow.after b/internal/ui/diffview/testdata/TestNarrow.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestNarrow.after rename to internal/ui/diffview/testdata/TestNarrow.after diff --git a/internal/tui/exp/diffview/testdata/TestNarrow.before b/internal/ui/diffview/testdata/TestNarrow.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestNarrow.before rename to internal/ui/diffview/testdata/TestNarrow.before diff --git a/internal/tui/exp/diffview/testdata/TestTabs.after b/internal/ui/diffview/testdata/TestTabs.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestTabs.after rename to internal/ui/diffview/testdata/TestTabs.after diff --git a/internal/tui/exp/diffview/testdata/TestTabs.before b/internal/ui/diffview/testdata/TestTabs.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestTabs.before rename to internal/ui/diffview/testdata/TestTabs.before diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/Unified.golden b/internal/ui/diffview/testdata/TestUdiff/Unified.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/Unified.golden rename to internal/ui/diffview/testdata/TestUdiff/Unified.golden diff --git a/internal/tui/exp/diffview/udiff_test.go b/internal/ui/diffview/udiff_test.go similarity index 100% rename from internal/tui/exp/diffview/udiff_test.go rename to internal/ui/diffview/udiff_test.go diff --git a/internal/tui/exp/diffview/util.go b/internal/ui/diffview/util.go similarity index 100% rename from internal/tui/exp/diffview/util.go rename to internal/ui/diffview/util.go diff --git a/internal/tui/exp/diffview/util_test.go b/internal/ui/diffview/util_test.go similarity index 100% rename from internal/tui/exp/diffview/util_test.go rename to internal/ui/diffview/util_test.go diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index 07039433dded1647646704959791dfcad7d3d69f..5af0ca7c4776cd45371d2a57e3a13dc6195b524e 100644 --- a/internal/ui/image/image.go +++ b/internal/ui/image/image.go @@ -12,7 +12,7 @@ import ( "sync" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/crush/internal/ui/util" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi/kitty" "github.com/disintegration/imaging" @@ -169,8 +169,8 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i }, }); err != nil { slog.Error("Failed to encode image for kitty graphics", "err", err) - return uiutil.InfoMsg{ - Type: uiutil.InfoTypeError, + return util.InfoMsg{ + Type: util.InfoTypeError, Msg: "failed to encode image", } } diff --git a/internal/ui/logo/logo.go b/internal/ui/logo/logo.go index 9f4cdfef36723cc69dd13f4a60dcd76f0c8f9904..68387d4c0ba2c8914929d041e149f4b23ff3694b 100644 --- a/internal/ui/logo/logo.go +++ b/internal/ui/logo/logo.go @@ -8,7 +8,7 @@ import ( "charm.land/lipgloss/v2" "github.com/MakeNowJust/heredoc" - "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/slice" ) @@ -34,7 +34,7 @@ type Opts struct { // // The compact argument determines whether it renders compact for the sidebar // or wider for the main pane. -func Render(version string, compact bool, o Opts) string { +func Render(s *styles.Styles, version string, compact bool, o Opts) string { const charm = " Charm™" fg := func(c color.Color, s string) string { @@ -59,7 +59,7 @@ func Render(version string, compact bool, o Opts) string { crushWidth := lipgloss.Width(crush) b := new(strings.Builder) for r := range strings.SplitSeq(crush, "\n") { - fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB)) + fmt.Fprintln(b, styles.ApplyForegroundGrad(s, r, o.TitleColorA, o.TitleColorB)) } crush = b.String() @@ -117,14 +117,13 @@ func Render(version string, compact bool, o Opts) string { // SmallRender renders a smaller version of the Crush logo, suitable for // smaller windows or sidebar usage. -func SmallRender(width int) string { - t := styles.CurrentTheme() - title := t.S().Base.Foreground(t.Secondary).Render("Charm™") - title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary)) +func SmallRender(t *styles.Styles, width int) string { + title := t.Base.Foreground(t.Secondary).Render("Charm™") + title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad(t, "Crush", t.Secondary, t.Primary)) remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush" if remainingWidth > 0 { lines := strings.Repeat("╱", remainingWidth) - title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines)) + title = fmt.Sprintf("%s %s", title, t.Base.Foreground(t.Primary).Render(lines)) } return title } diff --git a/internal/ui/model/filter.go b/internal/ui/model/filter.go new file mode 100644 index 0000000000000000000000000000000000000000..b28a4d061b2e2adad0d712de014e0ccf610e0485 --- /dev/null +++ b/internal/ui/model/filter.go @@ -0,0 +1,22 @@ +package model + +import ( + "time" + + tea "charm.land/bubbletea/v2" +) + +var lastMouseEvent time.Time + +func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg { + switch msg.(type) { + case tea.MouseWheelMsg, tea.MouseMotionMsg: + now := time.Now() + // trackpad is sending too many requests + if now.Sub(lastMouseEvent) < 15*time.Millisecond { + return nil + } + lastMouseEvent = now + } + return msg +} diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go index 1cd481f2f9a3625ba0ed8f12c8450265c0aa5ef0..0a6ec0775b9f21da9bac4ed5ac2a7013457176a1 100644 --- a/internal/ui/model/onboarding.go +++ b/internal/ui/model/onboarding.go @@ -13,7 +13,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/ui/common" - "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/crush/internal/ui/util" ) // markProjectInitialized marks the current project as initialized in the config. @@ -57,7 +57,7 @@ func (m *UI) initializeProject() tea.Cmd { initialize := func() tea.Msg { initPrompt, err := agent.InitializePrompt(*cfg) if err != nil { - return uiutil.InfoMsg{Type: uiutil.InfoTypeError, Msg: err.Error()} + return util.InfoMsg{Type: util.InfoTypeError, Msg: err.Error()} } return sendMessageMsg{Content: initPrompt} } diff --git a/internal/ui/model/session.go b/internal/ui/model/session.go index 38fd718db9cf2b44eb48538a9debb25870b90a7d..5b38da8b0d042486b19060047d2c715d514aef82 100644 --- a/internal/ui/model/session.go +++ b/internal/ui/model/session.go @@ -15,7 +15,7 @@ import ( "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/styles" - "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/crush/internal/ui/util" "github.com/charmbracelet/x/ansi" ) @@ -44,13 +44,13 @@ func (m *UI) loadSession(sessionID string) tea.Cmd { session, err := m.com.App.Sessions.Get(context.Background(), sessionID) if err != nil { // TODO: better error handling - return uiutil.ReportError(err)() + return util.ReportError(err)() } files, err := m.com.App.History.ListBySession(context.Background(), sessionID) if err != nil { // TODO: better error handling - return uiutil.ReportError(err)() + return util.ReportError(err)() } filesByPath := make(map[string][]history.File) diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 7316025aaedad67688b226cf1c7c37314f3b7a30..c3498b964ca5ebbc2446ffc31855a1c225a7ab5e 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -117,7 +117,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) { cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width) sidebarLogo := m.sidebarLogo if height < logoHeightBreakpoint { - sidebarLogo = logo.SmallRender(width) + sidebarLogo = logo.SmallRender(m.com.Styles, width) } blocks := []string{ sidebarLogo, diff --git a/internal/ui/model/status.go b/internal/ui/model/status.go index 2e1b9396e32b970c663cb755e2dc74f6c9f5eca0..66dd4082bcc90470129b4a8ebf4ebd65e8567d6c 100644 --- a/internal/ui/model/status.go +++ b/internal/ui/model/status.go @@ -7,7 +7,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/ui/common" - "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/crush/internal/ui/util" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" ) @@ -21,7 +21,7 @@ type Status struct { hideHelp bool help help.Model helpKm help.KeyMap - msg uiutil.InfoMsg + msg util.InfoMsg } // NewStatus creates a new status bar and help model. @@ -35,13 +35,13 @@ func NewStatus(com *common.Common, km help.KeyMap) *Status { } // SetInfoMsg sets the status info message. -func (s *Status) SetInfoMsg(msg uiutil.InfoMsg) { +func (s *Status) SetInfoMsg(msg util.InfoMsg) { s.msg = msg } // ClearInfoMsg clears the status info message. func (s *Status) ClearInfoMsg() { - s.msg = uiutil.InfoMsg{} + s.msg = util.InfoMsg{} } // SetWidth sets the width of the status bar and help view. @@ -79,19 +79,19 @@ func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) { var indStyle lipgloss.Style var msgStyle lipgloss.Style switch s.msg.Type { - case uiutil.InfoTypeError: + case util.InfoTypeError: indStyle = s.com.Styles.Status.ErrorIndicator msgStyle = s.com.Styles.Status.ErrorMessage - case uiutil.InfoTypeWarn: + case util.InfoTypeWarn: indStyle = s.com.Styles.Status.WarnIndicator msgStyle = s.com.Styles.Status.WarnMessage - case uiutil.InfoTypeUpdate: + case util.InfoTypeUpdate: indStyle = s.com.Styles.Status.UpdateIndicator msgStyle = s.com.Styles.Status.UpdateMessage - case uiutil.InfoTypeInfo: + case util.InfoTypeInfo: indStyle = s.com.Styles.Status.InfoIndicator msgStyle = s.com.Styles.Status.InfoMessage - case uiutil.InfoTypeSuccess: + case util.InfoTypeSuccess: indStyle = s.com.Styles.Status.SuccessIndicator msgStyle = s.com.Styles.Status.SuccessMessage } @@ -109,6 +109,6 @@ func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) { // given TTL. func clearInfoMsgCmd(ttl time.Duration) tea.Cmd { return tea.Tick(ttl, func(time.Time) tea.Msg { - return uiutil.ClearStatusMsg{} + return util.ClearStatusMsg{} }) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index d84d95c516892e8fa9538664dc0a2549dfa09fe1..65d1e720cd91d50c87d57f5409a42595915c1e40 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -43,7 +43,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/dialog" "github.com/charmbracelet/crush/internal/ui/logo" "github.com/charmbracelet/crush/internal/ui/styles" - "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/crush/internal/ui/util" "github.com/charmbracelet/crush/internal/version" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/ultraviolet/screen" @@ -391,7 +391,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sessionFiles = msg.files msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID) if err != nil { - cmds = append(cmds, uiutil.ReportError(err)) + cmds = append(cmds, util.ReportError(err)) break } if cmd := m.setSessionMessages(msgs); cmd != nil { @@ -697,14 +697,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { cmds = append(cmds, cmd) } - case uiutil.InfoMsg: + case util.InfoMsg: m.status.SetInfoMsg(msg) ttl := msg.TTL if ttl <= 0 { ttl = DefaultStatusTTL } cmds = append(cmds, clearInfoMsgCmd(ttl)) - case uiutil.ClearStatusMsg: + case util.ClearStatusMsg: m.status.ClearInfoMsg() case completions.FilesLoadedMsg: // Handle async file loading for completions. @@ -1154,7 +1154,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionNewSession: if m.isAgentBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session...")) break } if cmd := m.newSession(); cmd != nil { @@ -1163,13 +1163,13 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionSummarize: if m.isAgentBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session...")) + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session...")) break } cmds = append(cmds, func() tea.Msg { err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) if err != nil { - return uiutil.ReportError(err)() + return util.ReportError(err)() } return nil }) @@ -1179,7 +1179,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionExternalEditor: if m.isAgentBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait...")) + cmds = append(cmds, util.ReportWarn("Agent is working, please wait...")) break } cmds = append(cmds, m.openEditor(m.textarea.Value())) @@ -1191,32 +1191,32 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { cmds = append(cmds, func() tea.Msg { cfg := m.com.Config() if cfg == nil { - return uiutil.ReportError(errors.New("configuration not found"))() + return util.ReportError(errors.New("configuration not found"))() } agentCfg, ok := cfg.Agents[config.AgentCoder] if !ok { - return uiutil.ReportError(errors.New("agent configuration not found"))() + return util.ReportError(errors.New("agent configuration not found"))() } currentModel := cfg.Models[agentCfg.Model] currentModel.Think = !currentModel.Think if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil { - return uiutil.ReportError(err)() + return util.ReportError(err)() } m.com.App.UpdateAgentModel(context.TODO()) status := "disabled" if currentModel.Think { status = "enabled" } - return uiutil.NewInfoMsg("Thinking mode " + status) + return util.NewInfoMsg("Thinking mode " + status) }) m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionQuit: cmds = append(cmds, tea.Quit) case dialog.ActionInitializeProject: if m.isAgentBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session...")) + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session...")) break } cmds = append(cmds, m.initializeProject()) @@ -1224,13 +1224,13 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { case dialog.ActionSelectModel: if m.isAgentBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait...")) break } cfg := m.com.Config() if cfg == nil { - cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found"))) + cmds = append(cmds, util.ReportError(errors.New("configuration not found"))) break } @@ -1254,23 +1254,23 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { } if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil { - cmds = append(cmds, uiutil.ReportError(err)) + cmds = append(cmds, util.ReportError(err)) } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok { // Ensure small model is set is unset. smallModel := m.com.App.GetDefaultSmallModel(providerID) if err := cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallModel); err != nil { - cmds = append(cmds, uiutil.ReportError(err)) + cmds = append(cmds, util.ReportError(err)) } } cmds = append(cmds, func() tea.Msg { if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) - return uiutil.NewInfoMsg(modelMsg) + return util.NewInfoMsg(modelMsg) }) m.dialog.CloseDialog(dialog.APIKeyInputID) @@ -1281,37 +1281,37 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { m.setState(uiLanding, uiFocusEditor) m.com.Config().SetupAgents() if err := m.com.App.InitCoderAgent(context.TODO()); err != nil { - cmds = append(cmds, uiutil.ReportError(err)) + cmds = append(cmds, util.ReportError(err)) } } case dialog.ActionSelectReasoningEffort: if m.isAgentBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait...")) break } cfg := m.com.Config() if cfg == nil { - cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found"))) + cmds = append(cmds, util.ReportError(errors.New("configuration not found"))) break } agentCfg, ok := cfg.Agents[config.AgentCoder] if !ok { - cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found"))) + cmds = append(cmds, util.ReportError(errors.New("agent configuration not found"))) break } currentModel := cfg.Models[agentCfg.Model] currentModel.ReasoningEffort = msg.Effort if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil { - cmds = append(cmds, uiutil.ReportError(err)) + cmds = append(cmds, util.ReportError(err)) break } cmds = append(cmds, func() tea.Msg { m.com.App.UpdateAgentModel(context.TODO()) - return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort) + return util.NewInfoMsg("Reasoning effort set to " + msg.Effort) }) m.dialog.CloseDialog(dialog.ReasoningID) case dialog.ActionPermissionResponse: @@ -1372,7 +1372,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { } cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args)) default: - cmds = append(cmds, uiutil.CmdHandler(msg)) + cmds = append(cmds, util.CmdHandler(msg)) } return tea.Batch(cmds...) @@ -1464,7 +1464,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } case key.Matches(msg, m.keyMap.Suspend): if m.isAgentBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait...")) return true } cmds = append(cmds, tea.Suspend) @@ -1566,7 +1566,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { break } if m.isAgentBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session...")) break } if cmd := m.newSession(); cmd != nil { @@ -1581,7 +1581,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } case key.Matches(msg, m.keyMap.Editor.OpenEditor): if m.isAgentBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait...")) + cmds = append(cmds, util.ReportWarn("Agent is working, please wait...")) break } cmds = append(cmds, m.openEditor(m.textarea.Value())) @@ -1681,7 +1681,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { break } if m.isAgentBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) + cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session...")) break } m.focus = uiFocusEditor @@ -2152,7 +2152,7 @@ func (m *UI) toggleCompactMode() tea.Cmd { err := m.com.Config().SetCompactMode(m.forceCompactMode) if err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } m.updateLayoutAndSize() @@ -2382,11 +2382,11 @@ type layout struct { func (m *UI) openEditor(value string) tea.Cmd { tmpfile, err := os.CreateTemp("", "msg_*.md") if err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } defer tmpfile.Close() //nolint:errcheck if _, err := tmpfile.WriteString(value); err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } cmd, err := editor.Command( "crush", @@ -2397,18 +2397,18 @@ func (m *UI) openEditor(value string) tea.Cmd { ), ) if err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } return tea.ExecProcess(cmd, func(err error) tea.Msg { if err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } content, err := os.ReadFile(tmpfile.Name()) if err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } if len(content) == 0 { - return uiutil.ReportWarn("Message is empty") + return util.ReportWarn("Message is empty") } os.Remove(tmpfile.Name()) return openEditorMsg{ @@ -2607,14 +2607,14 @@ func (m *UI) cacheSidebarLogo(width int) { // sendMessage sends a message with the given content and attachments. func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd { if m.com.App.AgentCoordinator == nil { - return uiutil.ReportError(fmt.Errorf("coder agent is not initialized")) + return util.ReportError(fmt.Errorf("coder agent is not initialized")) } var cmds []tea.Cmd if !m.hasSession() { newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session") if err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } if m.forceCompactMode { m.isCompact = true @@ -2640,8 +2640,8 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea. if isCancelErr || isPermissionErr { return nil } - return uiutil.InfoMsg{ - Type: uiutil.InfoTypeError, + return util.InfoMsg{ + Type: util.InfoTypeError, Msg: err.Error(), } } @@ -2748,7 +2748,7 @@ func (m *UI) openModelsDialog() tea.Cmd { isOnboarding := m.state == uiOnboarding modelsDialog, err := dialog.NewModels(m.com, isOnboarding) if err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } m.dialog.OpenDialog(modelsDialog) @@ -2771,7 +2771,7 @@ func (m *UI) openCommandsDialog() tea.Cmd { commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts) if err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } m.dialog.OpenDialog(commands) @@ -2788,7 +2788,7 @@ func (m *UI) openReasoningDialog() tea.Cmd { reasoningDialog, err := dialog.NewReasoning(m.com) if err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } m.dialog.OpenDialog(reasoningDialog) @@ -2812,7 +2812,7 @@ func (m *UI) openSessionsDialog() tea.Cmd { dialog, err := dialog.NewSessions(m.com, selectedSessionID) if err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } m.dialog.OpenDialog(dialog) @@ -2902,7 +2902,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { return func() tea.Msg { content := []byte(msg.Content) if int64(len(content)) > common.MaxAttachmentSize { - return uiutil.ReportWarn("Paste is too big (>5mb)") + return util.ReportWarn("Paste is too big (>5mb)") } name := fmt.Sprintf("paste_%d.txt", m.pasteIdx()) mimeBufferSize := min(512, len(content)) @@ -2961,18 +2961,18 @@ func (m *UI) handleFilePathPaste(path string) tea.Cmd { return func() tea.Msg { fileInfo, err := os.Stat(path) if err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } if fileInfo.IsDir() { - return uiutil.ReportWarn("Cannot attach a directory") + return util.ReportWarn("Cannot attach a directory") } if fileInfo.Size() > common.MaxAttachmentSize { - return uiutil.ReportWarn("File is too big (>5mb)") + return util.ReportWarn("File is too big (>5mb)") } content, err := os.ReadFile(path) if err != nil { - return uiutil.ReportError(err) + return util.ReportError(err) } mimeBufferSize := min(512, len(content)) @@ -3059,7 +3059,7 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments) if err != nil { // TODO: make this better - return uiutil.ReportError(err)() + return util.ReportError(err)() } if prompt == "" { @@ -3095,7 +3095,7 @@ func (m *UI) copyChatHighlight() tea.Cmd { // renderLogo renders the Crush logo with the given styles and dimensions. func renderLogo(t *styles.Styles, compact bool, width int) string { - return logo.Render(version.Version, compact, logo.Opts{ + return logo.Render(t, version.Version, compact, logo.Opts{ FieldColor: t.LogoFieldColor, TitleColorA: t.LogoTitleColorA, TitleColorB: t.LogoTitleColorB, diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 45aa6dc998226469f800883fb4ff9452cb56481a..c1d548ec25a2afb0645927a22fa5821ba8c3b968 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -12,7 +12,7 @@ import ( "charm.land/glamour/v2/ansi" "charm.land/lipgloss/v2" "github.com/alecthomas/chroma/v2" - "github.com/charmbracelet/crush/internal/tui/exp/diffview" + "github.com/charmbracelet/crush/internal/ui/diffview" "github.com/charmbracelet/x/exp/charmtone" ) diff --git a/internal/uiutil/uiutil.go b/internal/ui/util/util.go similarity index 90% rename from internal/uiutil/uiutil.go rename to internal/ui/util/util.go index d0443f9c1e4b40fc23b3fb9d597a1d0cd785e1b0..7a53df7d1e4e676b3b142de9ec74deff614c8af2 100644 --- a/internal/uiutil/uiutil.go +++ b/internal/ui/util/util.go @@ -1,7 +1,5 @@ -// Package uiutil provides utility functions for UI message handling. -// TODO: Move to internal/ui/ once the new UI migration -// is finalized. -package uiutil +// Package util provides utility functions for UI message handling. +package util import ( "context" diff --git a/internal/uicmd/uicmd.go b/internal/uicmd/uicmd.go deleted file mode 100644 index c2ce2d89d1457459ac84c9e97c6e68b371e042d8..0000000000000000000000000000000000000000 --- a/internal/uicmd/uicmd.go +++ /dev/null @@ -1,314 +0,0 @@ -// Package uicmd provides functionality to load and handle custom commands -// from markdown files and MCP prompts. -// TODO: Move this into internal/ui after refactoring. -// TODO: DELETE when we delete the old tui -package uicmd - -import ( - "cmp" - "context" - "fmt" - "io/fs" - "os" - "path/filepath" - "regexp" - "strings" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/agent/tools/mcp" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/home" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/util" -) - -type CommandType uint - -func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] } - -const ( - SystemCommands CommandType = iota - UserCommands - MCPPrompts -) - -// Command represents a command that can be executed -type Command struct { - ID string - Title string - Description string - Shortcut string // Optional shortcut for the command - Handler func(cmd Command) tea.Cmd -} - -// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog. -type ShowArgumentsDialogMsg struct { - CommandID string - Description string - ArgNames []string - OnSubmit func(args map[string]string) tea.Cmd -} - -// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed. -type CloseArgumentsDialogMsg struct { - Submit bool - CommandID string - Content string - Args map[string]string -} - -const ( - userCommandPrefix = "user:" - projectCommandPrefix = "project:" -) - -var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`) - -type commandLoader struct { - sources []commandSource -} - -type commandSource struct { - path string - prefix string -} - -func LoadCustomCommands() ([]Command, error) { - return LoadCustomCommandsFromConfig(config.Get()) -} - -func LoadCustomCommandsFromConfig(cfg *config.Config) ([]Command, error) { - if cfg == nil { - return nil, fmt.Errorf("config not loaded") - } - - loader := &commandLoader{ - sources: buildCommandSources(cfg), - } - - return loader.loadAll() -} - -func buildCommandSources(cfg *config.Config) []commandSource { - var sources []commandSource - - // XDG config directory - if dir := getXDGCommandsDir(); dir != "" { - sources = append(sources, commandSource{ - path: dir, - prefix: userCommandPrefix, - }) - } - - // Home directory - if home := home.Dir(); home != "" { - sources = append(sources, commandSource{ - path: filepath.Join(home, ".crush", "commands"), - prefix: userCommandPrefix, - }) - } - - // Project directory - sources = append(sources, commandSource{ - path: filepath.Join(cfg.Options.DataDirectory, "commands"), - prefix: projectCommandPrefix, - }) - - return sources -} - -func getXDGCommandsDir() string { - xdgHome := os.Getenv("XDG_CONFIG_HOME") - if xdgHome == "" { - if home := home.Dir(); home != "" { - xdgHome = filepath.Join(home, ".config") - } - } - if xdgHome != "" { - return filepath.Join(xdgHome, "crush", "commands") - } - return "" -} - -func (l *commandLoader) loadAll() ([]Command, error) { - var commands []Command - - for _, source := range l.sources { - if cmds, err := l.loadFromSource(source); err == nil { - commands = append(commands, cmds...) - } - } - - return commands, nil -} - -func (l *commandLoader) loadFromSource(source commandSource) ([]Command, error) { - if err := ensureDir(source.path); err != nil { - return nil, err - } - - var commands []Command - - err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) { - return err - } - - cmd, err := l.loadCommand(path, source.path, source.prefix) - if err != nil { - return nil // Skip invalid files - } - - commands = append(commands, cmd) - return nil - }) - - return commands, err -} - -func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, error) { - content, err := os.ReadFile(path) - if err != nil { - return Command{}, err - } - - id := buildCommandID(path, baseDir, prefix) - desc := fmt.Sprintf("Custom command from %s", filepath.Base(path)) - - return Command{ - ID: id, - Title: id, - Description: desc, - Handler: createCommandHandler(id, desc, string(content)), - }, nil -} - -func buildCommandID(path, baseDir, prefix string) string { - relPath, _ := filepath.Rel(baseDir, path) - parts := strings.Split(relPath, string(filepath.Separator)) - - // Remove .md extension from last part - if len(parts) > 0 { - lastIdx := len(parts) - 1 - parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx])) - } - - return prefix + strings.Join(parts, ":") -} - -func createCommandHandler(id, desc, content string) func(Command) tea.Cmd { - return func(cmd Command) tea.Cmd { - args := extractArgNames(content) - - if len(args) == 0 { - return util.CmdHandler(CommandRunCustomMsg{ - Content: content, - }) - } - return util.CmdHandler(ShowArgumentsDialogMsg{ - CommandID: id, - Description: desc, - ArgNames: args, - OnSubmit: func(args map[string]string) tea.Cmd { - return execUserPrompt(content, args) - }, - }) - } -} - -func execUserPrompt(content string, args map[string]string) tea.Cmd { - return func() tea.Msg { - for name, value := range args { - placeholder := "$" + name - content = strings.ReplaceAll(content, placeholder, value) - } - return CommandRunCustomMsg{ - Content: content, - } - } -} - -func extractArgNames(content string) []string { - matches := namedArgPattern.FindAllStringSubmatch(content, -1) - if len(matches) == 0 { - return nil - } - - seen := make(map[string]bool) - var args []string - - for _, match := range matches { - arg := match[1] - if !seen[arg] { - seen[arg] = true - args = append(args, arg) - } - } - - return args -} - -func ensureDir(path string) error { - if _, err := os.Stat(path); os.IsNotExist(err) { - return os.MkdirAll(path, 0o755) - } - return nil -} - -func isMarkdownFile(name string) bool { - return strings.HasSuffix(strings.ToLower(name), ".md") -} - -type CommandRunCustomMsg struct { - Content string -} - -func LoadMCPPrompts() []Command { - var commands []Command - for mcpName, prompts := range mcp.Prompts() { - for _, prompt := range prompts { - key := mcpName + ":" + prompt.Name - commands = append(commands, Command{ - ID: key, - Title: cmp.Or(prompt.Title, prompt.Name), - Description: prompt.Description, - Handler: createMCPPromptHandler(mcpName, prompt.Name, prompt), - }) - } - } - - return commands -} - -func createMCPPromptHandler(mcpName, promptName string, prompt *mcp.Prompt) func(Command) tea.Cmd { - return func(cmd Command) tea.Cmd { - if len(prompt.Arguments) == 0 { - return execMCPPrompt(mcpName, promptName, nil) - } - return util.CmdHandler(ShowMCPPromptArgumentsDialogMsg{ - Prompt: prompt, - OnSubmit: func(args map[string]string) tea.Cmd { - return execMCPPrompt(mcpName, promptName, args) - }, - }) - } -} - -func execMCPPrompt(clientName, promptName string, args map[string]string) tea.Cmd { - return func() tea.Msg { - ctx := context.Background() - result, err := mcp.GetPromptMessages(ctx, clientName, promptName, args) - if err != nil { - return util.ReportError(err) - } - - return chat.SendMsg{ - Text: strings.Join(result, " "), - } - } -} - -type ShowMCPPromptArgumentsDialogMsg struct { - Prompt *mcp.Prompt - OnSubmit func(arg map[string]string) tea.Cmd -} From 8adfe70c4454363bc839ca405657de91da01998b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 5 Feb 2026 14:06:24 +0100 Subject: [PATCH 044/125] fix: hyper provider cancel (#2133) --- internal/agent/hyper/provider.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/agent/hyper/provider.go b/internal/agent/hyper/provider.go index bba8542549827622baa0a47b40e39765e5dc9376..e4c1cd85eb1171226f48ff496ea238ff2121619d 100644 --- a/internal/agent/hyper/provider.go +++ b/internal/agent/hyper/provider.go @@ -252,10 +252,16 @@ func (m *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy. continue } } - if err := scanner.Err(); err != nil && - !errors.Is(err, context.Canceled) && - !errors.Is(err, context.DeadlineExceeded) { - yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err}) + if err := scanner.Err(); err != nil { + if sawFinish && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) { + // If we already saw an explicit finish event, treat cancellation as a no-op. + } else { + _ = yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err}) + return + } + } + if err := ctx.Err(); err != nil && !sawFinish { + _ = yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err}) return } // flush any pending data From d29d0e21216eb5bb68bce18134e1fba8d2d7d362 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 5 Feb 2026 14:06:43 +0100 Subject: [PATCH 045/125] fix: realtime session file changes (#2134) --- internal/ui/model/session.go | 168 +++++++++++++---------------------- internal/ui/model/ui.go | 7 ++ 2 files changed, 70 insertions(+), 105 deletions(-) diff --git a/internal/ui/model/session.go b/internal/ui/model/session.go index 5b38da8b0d042486b19060047d2c715d514aef82..1438d0a914556574d513d3606bb1481cde008709 100644 --- a/internal/ui/model/session.go +++ b/internal/ui/model/session.go @@ -43,63 +43,68 @@ func (m *UI) loadSession(sessionID string) tea.Cmd { return func() tea.Msg { session, err := m.com.App.Sessions.Get(context.Background(), sessionID) if err != nil { - // TODO: better error handling - return util.ReportError(err)() + return util.ReportError(err) } - files, err := m.com.App.History.ListBySession(context.Background(), sessionID) + sessionFiles, err := m.loadSessionFiles(sessionID) if err != nil { - // TODO: better error handling - return util.ReportError(err)() + return util.ReportError(err) } - filesByPath := make(map[string][]history.File) - for _, f := range files { - filesByPath[f.Path] = append(filesByPath[f.Path], f) + return loadSessionMsg{ + session: &session, + files: sessionFiles, } + } +} - sessionFiles := make([]SessionFile, 0, len(filesByPath)) - for _, versions := range filesByPath { - if len(versions) == 0 { - continue - } - - first := versions[0] - last := versions[0] - for _, v := range versions { - if v.Version < first.Version { - first = v - } - if v.Version > last.Version { - last = v - } - } - - _, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path) +func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) { + files, err := m.com.App.History.ListBySession(context.Background(), sessionID) + if err != nil { + return nil, err + } - sessionFiles = append(sessionFiles, SessionFile{ - FirstVersion: first, - LatestVersion: last, - Additions: additions, - Deletions: deletions, - }) + filesByPath := make(map[string][]history.File) + for _, f := range files { + filesByPath[f.Path] = append(filesByPath[f.Path], f) + } + sessionFiles := make([]SessionFile, 0, len(filesByPath)) + for _, versions := range filesByPath { + if len(versions) == 0 { + continue } - slices.SortFunc(sessionFiles, func(a, b SessionFile) int { - if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt { - return -1 + first := versions[0] + last := versions[0] + for _, v := range versions { + if v.Version < first.Version { + first = v } - if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt { - return 1 + if v.Version > last.Version { + last = v } - return 0 + } + + _, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path) + + sessionFiles = append(sessionFiles, SessionFile{ + FirstVersion: first, + LatestVersion: last, + Additions: additions, + Deletions: deletions, }) + } - return loadSessionMsg{ - session: &session, - files: sessionFiles, + slices.SortFunc(sessionFiles, func(a, b SessionFile) int { + if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt { + return -1 } - } + if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt { + return 1 + } + return 0 + }) + return sessionFiles, nil } // handleFileEvent processes file change events and updates the session file @@ -110,59 +115,14 @@ func (m *UI) handleFileEvent(file history.File) tea.Cmd { } return func() tea.Msg { - existingIdx := -1 - for i, sf := range m.sessionFiles { - if sf.FirstVersion.Path == file.Path { - existingIdx = i - break - } - } - - if existingIdx == -1 { - newFiles := make([]SessionFile, 0, len(m.sessionFiles)+1) - newFiles = append(newFiles, SessionFile{ - FirstVersion: file, - LatestVersion: file, - Additions: 0, - Deletions: 0, - }) - newFiles = append(newFiles, m.sessionFiles...) - - return loadSessionMsg{ - session: m.session, - files: newFiles, - } - } - - updated := m.sessionFiles[existingIdx] - - if file.Version < updated.FirstVersion.Version { - updated.FirstVersion = file - } - - if file.Version > updated.LatestVersion.Version { - updated.LatestVersion = file - } - - _, additions, deletions := diff.GenerateDiff( - updated.FirstVersion.Content, - updated.LatestVersion.Content, - updated.FirstVersion.Path, - ) - updated.Additions = additions - updated.Deletions = deletions - - newFiles := make([]SessionFile, 0, len(m.sessionFiles)) - newFiles = append(newFiles, updated) - for i, sf := range m.sessionFiles { - if i != existingIdx { - newFiles = append(newFiles, sf) - } + sessionFiles, err := m.loadSessionFiles(m.session.ID) + // could not load session files + if err != nil { + return util.NewErrorMsg(err) } - return loadSessionMsg{ - session: m.session, - files: newFiles, + return sessionFilesUpdatesMsg{ + sessionFiles: sessionFiles, } } } @@ -177,9 +137,15 @@ func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string { title = common.Section(t, "Modified Files", width) } list := t.Subtle.Render("None") - - if len(m.sessionFiles) > 0 { - list = fileList(t, cwd, m.sessionFiles, width, maxItems) + var filesWithChanges []SessionFile + for _, f := range m.sessionFiles { + if f.Additions == 0 && f.Deletions == 0 { + continue + } + filesWithChanges = append(filesWithChanges, f) + } + if len(filesWithChanges) > 0 { + list = fileList(t, cwd, filesWithChanges, width, maxItems) } return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) @@ -187,21 +153,13 @@ func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string { // fileList renders a list of files with their diff statistics, truncating to // maxItems and showing a "...and N more" message if needed. -func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string { +func fileList(t *styles.Styles, cwd string, filesWithChanges []SessionFile, width, maxItems int) string { if maxItems <= 0 { return "" } var renderedFiles []string filesShown := 0 - var filesWithChanges []SessionFile - for _, f := range files { - if f.Additions == 0 && f.Deletions == 0 { - continue - } - filesWithChanges = append(filesWithChanges, f) - } - for _, f := range filesWithChanges { // Skip files with no changes if filesShown >= maxItems { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 65d1e720cd91d50c87d57f5409a42595915c1e40..a01fafc2905f84e31fce9ce1914bdd8274e26ad4 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -109,6 +109,11 @@ type ( // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard. copyChatHighlightMsg struct{} + + // sessionFilesUpdatesMsg is sent when the files for this session have been updated + sessionFilesUpdatesMsg struct { + sessionFiles []SessionFile + } ) // UI represents the main user interface model. @@ -409,6 +414,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.historyReset() cmds = append(cmds, m.loadPromptHistory()) m.updateLayoutAndSize() + case sessionFilesUpdatesMsg: + m.sessionFiles = msg.sessionFiles case sendMessageMsg: cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...)) From f0c9985dde2485c8e384de20f67959b9e4fb9090 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 5 Feb 2026 14:12:01 +0100 Subject: [PATCH 046/125] revert: the width changes in #2127 (#2135) This reverts commit fd437468b74e250f4d197b29c7857ce1ebbb406e. --- internal/ui/chat/agent.go | 14 +++++++------ internal/ui/chat/assistant.go | 10 +++++---- internal/ui/chat/bash.go | 19 +++++++++-------- internal/ui/chat/diagnostics.go | 7 ++++--- internal/ui/chat/fetch.go | 27 ++++++++++++++----------- internal/ui/chat/file.go | 27 ++++++++++++++----------- internal/ui/chat/generic.go | 9 +++++---- internal/ui/chat/lsp_restart.go | 7 ++++--- internal/ui/chat/mcp.go | 11 +++++----- internal/ui/chat/messages.go | 5 +++++ internal/ui/chat/references.go | 7 ++++--- internal/ui/chat/search.go | 36 ++++++++++++++++++--------------- internal/ui/chat/todos.go | 9 +++++---- internal/ui/chat/tools.go | 8 ++++++++ internal/ui/chat/user.go | 14 +++++++------ 15 files changed, 124 insertions(+), 86 deletions(-) diff --git a/internal/ui/chat/agent.go b/internal/ui/chat/agent.go index 4784b314169f92efe4e80bf875eea5fd3780fe86..c2a439ff23d0bd046b75076ea30de68b60cdcc54 100644 --- a/internal/ui/chat/agent.go +++ b/internal/ui/chat/agent.go @@ -99,6 +99,7 @@ type AgentToolRenderContext struct { // RenderTool implements the [ToolRenderer] interface. func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 { return pendingTool(sty, "Agent", opts.Anim) } @@ -109,7 +110,7 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts prompt := params.Prompt prompt = strings.ReplaceAll(prompt, "\n", " ") - header := toolHeader(sty, opts.Status, "Agent", width, opts.Compact) + header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts.Compact) if opts.Compact { return header } @@ -119,7 +120,7 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts taskTagWidth := lipgloss.Width(taskTag) // Calculate remaining width for prompt. - remainingWidth := width - taskTagWidth - 3 // -3 for spacing + remainingWidth := min(cappedWidth-taskTagWidth-3, maxTextWidth-taskTagWidth-3) // -3 for spacing promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) @@ -156,7 +157,7 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts // Add body content when completed. if opts.HasResult() && opts.Result.Content != "" { - body := toolOutputMarkdownContent(sty, opts.Result.Content, width-toolBodyLeftPaddingTotal, opts.ExpandedContent) + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent) return joinToolParts(result, body) } @@ -229,6 +230,7 @@ type agenticFetchParams struct { // RenderTool implements the [ToolRenderer] interface. func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 { return pendingTool(sty, "Agentic Fetch", opts.Anim) } @@ -245,7 +247,7 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int toolParams = append(toolParams, params.URL) } - header := toolHeader(sty, opts.Status, "Agentic Fetch", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -255,7 +257,7 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int promptTagWidth := lipgloss.Width(promptTag) // Calculate remaining width for prompt text. - remainingWidth := width - promptTagWidth - 3 // -3 for spacing + remainingWidth := min(cappedWidth-promptTagWidth-3, maxTextWidth-promptTagWidth-3) // -3 for spacing promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) @@ -292,7 +294,7 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int // Add body content when completed. if opts.HasResult() && opts.Result.Content != "" { - body := toolOutputMarkdownContent(sty, opts.Result.Content, width-toolBodyLeftPaddingTotal, opts.ExpandedContent) + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent) return joinToolParts(result, body) } diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index b9aa19456eb05739484c0b4d1a28813a7b46bb11..4ce71dda2515e5489900c33eb716e1d6d884409a 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -79,20 +79,22 @@ func (a *AssistantMessageItem) ID() string { // RawRender implements [MessageItem]. func (a *AssistantMessageItem) RawRender(width int) string { + cappedWidth := cappedMessageWidth(width) + var spinner string if a.isSpinning() { spinner = a.renderSpinning() } - content, height, ok := a.getCachedRender(width) + content, height, ok := a.getCachedRender(cappedWidth) if !ok { - content = a.renderMessageContent(width) + content = a.renderMessageContent(cappedWidth) height = lipgloss.Height(content) // cache the rendered content - a.setCachedRender(content, width, height) + a.setCachedRender(content, cappedWidth, height) } - highlightedContent := a.renderHighlighted(content, width, height) + highlightedContent := a.renderHighlighted(content, cappedWidth, height) if spinner != "" { if highlightedContent != "" { highlightedContent += "\n\n" diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index 445043aef9809b69126d0c409596a299f6a3aa58..18be27ee01b4fcc21749789fc65ec0b71c2b0d4b 100644 --- a/internal/ui/chat/bash.go +++ b/internal/ui/chat/bash.go @@ -39,6 +39,7 @@ type BashToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Bash", opts.Anim) } @@ -57,7 +58,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * if meta.Background { description := cmp.Or(meta.Description, params.Command) content := "Command: " + params.Command + "\n" + opts.Result.Content - return renderJobTool(sty, opts, width, "Start", meta.ShellID, description, content) + return renderJobTool(sty, opts, cappedWidth, "Start", meta.ShellID, description, content) } // Regular bash command. @@ -68,12 +69,12 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "background", "true") } - header := toolHeader(sty, opts.Status, "Bash", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Bash", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -89,7 +90,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return header } - bodyWidth := width - toolBodyLeftPaddingTotal + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } @@ -120,13 +121,14 @@ type JobOutputToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Job", opts.Anim) } var params tools.JobOutputParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) } var description string @@ -141,7 +143,7 @@ func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, o if opts.HasResult() { content = opts.Result.Content } - return renderJobTool(sty, opts, width, "Output", params.ShellID, description, content) + return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content) } // ----------------------------------------------------------------------------- @@ -170,13 +172,14 @@ type JobKillToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Job", opts.Anim) } var params tools.JobKillParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) } var description string @@ -191,7 +194,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt if opts.HasResult() { content = opts.Result.Content } - return renderJobTool(sty, opts, width, "Kill", params.ShellID, description, content) + return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content) } // renderJobTool renders a job-related tool with the common pattern: diff --git a/internal/ui/chat/diagnostics.go b/internal/ui/chat/diagnostics.go index 16dbda3563b55d881944eea4328d1f2ff99d2d87..68d2ac4a00dc880c27904468008fb8f6b2fcf9c5 100644 --- a/internal/ui/chat/diagnostics.go +++ b/internal/ui/chat/diagnostics.go @@ -35,6 +35,7 @@ type DiagnosticsToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Diagnostics", opts.Anim) } @@ -48,12 +49,12 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, mainParam = fsext.PrettyPath(params.FilePath) } - header := toolHeader(sty, opts.Status, "Diagnostics", width, opts.Compact, mainParam) + header := toolHeader(sty, opts.Status, "Diagnostics", cappedWidth, opts.Compact, mainParam) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -61,7 +62,7 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, return header } - bodyWidth := width - toolBodyLeftPaddingTotal + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } diff --git a/internal/ui/chat/fetch.go b/internal/ui/chat/fetch.go index 588b2926258b01b8579330211de83eb266a5adcd..e3f3a809550385dfd0ec557e98151ffc731acc93 100644 --- a/internal/ui/chat/fetch.go +++ b/internal/ui/chat/fetch.go @@ -34,13 +34,14 @@ type FetchToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Fetch", opts.Anim) } var params tools.FetchParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) } toolParams := []string{params.URL} @@ -51,12 +52,12 @@ func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout)) } - header := toolHeader(sty, opts.Status, "Fetch", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -66,7 +67,7 @@ func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts // Determine file extension for syntax highlighting based on format. file := getFileExtensionForFormat(params.Format) - body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, width, opts.ExpandedContent) + body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.ExpandedContent) return joinToolParts(header, body) } @@ -108,22 +109,23 @@ type WebFetchToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Fetch", opts.Anim) } var params tools.WebFetchParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) } toolParams := []string{params.URL} - header := toolHeader(sty, opts.Status, "Fetch", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -131,7 +133,7 @@ func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, op return header } - body := toolOutputMarkdownContent(sty, opts.Result.Content, width, opts.ExpandedContent) + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent) return joinToolParts(header, body) } @@ -161,22 +163,23 @@ type WebSearchToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Search", opts.Anim) } var params tools.WebSearchParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) } toolParams := []string{params.Query} - header := toolHeader(sty, opts.Status, "Search", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Search", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -184,6 +187,6 @@ func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, o return header } - body := toolOutputMarkdownContent(sty, opts.Result.Content, width, opts.ExpandedContent) + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent) return joinToolParts(header, body) } diff --git a/internal/ui/chat/file.go b/internal/ui/chat/file.go index 13cb5104233af51756806cebb9b545b3bb5076f0..d558f79d597871bf6074d33c76b44549ee6725d5 100644 --- a/internal/ui/chat/file.go +++ b/internal/ui/chat/file.go @@ -37,13 +37,14 @@ type ViewToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "View", opts.Anim) } var params tools.ViewParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) } file := fsext.PrettyPath(params.FilePath) @@ -55,12 +56,12 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset)) } - header := toolHeader(sty, opts.Status, "View", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "View", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -86,7 +87,7 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * } // Render code content with syntax highlighting. - body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, width, opts.ExpandedContent) + body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, cappedWidth, opts.ExpandedContent) return joinToolParts(header, body) } @@ -116,22 +117,23 @@ type WriteToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Write", opts.Anim) } var params tools.WriteParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) } file := fsext.PrettyPath(params.FilePath) - header := toolHeader(sty, opts.Status, "Write", width, opts.Compact, file) + header := toolHeader(sty, opts.Status, "Write", cappedWidth, opts.Compact, file) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -140,7 +142,7 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts } // Render code content with syntax highlighting. - body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, width, opts.ExpandedContent) + body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent) return joinToolParts(header, body) } @@ -301,13 +303,14 @@ type DownloadToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Download", opts.Anim) } var params tools.DownloadParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) } toolParams := []string{params.URL} @@ -318,12 +321,12 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout)) } - header := toolHeader(sty, opts.Status, "Download", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Download", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -331,7 +334,7 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op return header } - bodyWidth := width - toolBodyLeftPaddingTotal + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } diff --git a/internal/ui/chat/generic.go b/internal/ui/chat/generic.go index 269bf651f7ec402d5e41aecabfb7aee0d9272cb5..6b0ac433028daf7a06c57f85c7799250e9652f6f 100644 --- a/internal/ui/chat/generic.go +++ b/internal/ui/chat/generic.go @@ -31,6 +31,7 @@ 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() { @@ -39,7 +40,7 @@ func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opt var params map[string]any if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) } var toolParams []string @@ -48,12 +49,12 @@ func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opt toolParams = append(toolParams, string(parsed)) } - header := toolHeader(sty, opts.Status, name, width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, name, cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -61,7 +62,7 @@ func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opt return header } - bodyWidth := width - toolBodyLeftPaddingTotal + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal // Handle image data. if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") { diff --git a/internal/ui/chat/lsp_restart.go b/internal/ui/chat/lsp_restart.go index 4ee188a42428167314cd34aa60828cb87d121b79..66c316fcaf7c949711babeb9ebe864e558ae5bc0 100644 --- a/internal/ui/chat/lsp_restart.go +++ b/internal/ui/chat/lsp_restart.go @@ -30,6 +30,7 @@ type LSPRestartToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (r *LSPRestartToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Restart LSP", opts.Anim) } @@ -42,12 +43,12 @@ func (r *LSPRestartToolRenderContext) RenderTool(sty *styles.Styles, width int, toolParams = append(toolParams, params.Name) } - header := toolHeader(sty, opts.Status, "Restart LSP", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Restart LSP", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -55,7 +56,7 @@ func (r *LSPRestartToolRenderContext) RenderTool(sty *styles.Styles, width int, return header } - bodyWidth := width - toolBodyLeftPaddingTotal + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } diff --git a/internal/ui/chat/mcp.go b/internal/ui/chat/mcp.go index 5cf750bacf7227744f06cc2d5253d98ad1713cbd..c4d124e7381a9ddaa39f56750367d3f2cf4d207f 100644 --- a/internal/ui/chat/mcp.go +++ b/internal/ui/chat/mcp.go @@ -32,9 +32,10 @@ type MCPToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) toolNameParts := strings.SplitN(opts.ToolCall.Name, "_", 3) if len(toolNameParts) != 3 { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid tool name"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid tool name"}, cappedWidth) } mcpName := prettyName(toolNameParts[1]) toolName := prettyName(toolNameParts[2]) @@ -50,7 +51,7 @@ func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *T var params map[string]any if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) } var toolParams []string @@ -59,12 +60,12 @@ func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *T toolParams = append(toolParams, string(parsed)) } - header := toolHeader(sty, opts.Status, name, width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, name, cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -72,7 +73,7 @@ func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *T return header } - bodyWidth := width - toolBodyLeftPaddingTotal + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal // see if the result is json var result json.RawMessage var body string diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 0c5a3ed6a8a9d65d26cf67adff5f308e3d82a929..0c5668a20d52c5975dc63cb37da8090e9aa0ca7f 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -245,6 +245,11 @@ func (a *AssistantInfoItem) renderContent(width int) string { return common.Section(a.sty, assistant, width) } +// cappedMessageWidth returns the maximum width for message content for readability. +func cappedMessageWidth(availableWidth int) int { + return min(availableWidth-MessageLeftPaddingTotal, maxTextWidth) +} + // ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It // returns all parts of the message as [MessageItem]s. // diff --git a/internal/ui/chat/references.go b/internal/ui/chat/references.go index 25fee7a15710c5ce1f470e470ff3b491da5000c3..2d7efe8df3ed38bf3768d7ae13c433fc05c17418 100644 --- a/internal/ui/chat/references.go +++ b/internal/ui/chat/references.go @@ -31,6 +31,7 @@ type ReferencesToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (r *ReferencesToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Find References", opts.Anim) } @@ -43,12 +44,12 @@ func (r *ReferencesToolRenderContext) RenderTool(sty *styles.Styles, width int, toolParams = append(toolParams, "path", fsext.PrettyPath(params.Path)) } - header := toolHeader(sty, opts.Status, "Find References", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Find References", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -56,7 +57,7 @@ func (r *ReferencesToolRenderContext) RenderTool(sty *styles.Styles, width int, return header } - bodyWidth := width - toolBodyLeftPaddingTotal + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } diff --git a/internal/ui/chat/search.go b/internal/ui/chat/search.go index 2a252936f63c41dd18afde4ef725ed43a3c23a95..2342f671fdaed3bfdcf56619864bd3b60987d8a6 100644 --- a/internal/ui/chat/search.go +++ b/internal/ui/chat/search.go @@ -35,13 +35,14 @@ type GlobToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Glob", opts.Anim) } var params tools.GlobParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) } toolParams := []string{params.Pattern} @@ -49,12 +50,12 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "path", params.Path) } - header := toolHeader(sty, opts.Status, "Glob", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Glob", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -62,7 +63,7 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return header } - bodyWidth := width - toolBodyLeftPaddingTotal + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } @@ -93,13 +94,14 @@ type GrepToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Grep", opts.Anim) } var params tools.GrepParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) } toolParams := []string{params.Pattern} @@ -113,12 +115,12 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "literal", "true") } - header := toolHeader(sty, opts.Status, "Grep", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Grep", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -126,7 +128,7 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return header } - bodyWidth := width - toolBodyLeftPaddingTotal + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } @@ -157,13 +159,14 @@ type LSToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "List", opts.Anim) } var params tools.LSParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) } path := params.Path @@ -172,12 +175,12 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To } path = fsext.PrettyPath(path) - header := toolHeader(sty, opts.Status, "List", width, opts.Compact, path) + header := toolHeader(sty, opts.Status, "List", cappedWidth, opts.Compact, path) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -185,7 +188,7 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To return header } - bodyWidth := width - toolBodyLeftPaddingTotal + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } @@ -216,13 +219,14 @@ type SourcegraphToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "Sourcegraph", opts.Anim) } var params tools.SourcegraphParams if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { - return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) } toolParams := []string{params.Query} @@ -233,12 +237,12 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow)) } - header := toolHeader(sty, opts.Status, "Sourcegraph", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Sourcegraph", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } @@ -246,7 +250,7 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, return header } - bodyWidth := width - toolBodyLeftPaddingTotal + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } diff --git a/internal/ui/chat/todos.go b/internal/ui/chat/todos.go index 42e9762b8bf1685495b65626bc36b1b3f45031a8..5678d0e47f4c3a808c13c1dc6209f9194e9f9482 100644 --- a/internal/ui/chat/todos.go +++ b/internal/ui/chat/todos.go @@ -39,6 +39,7 @@ type TodosToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) if opts.IsPending() { return pendingTool(sty, "To-Do", opts.Anim) } @@ -81,7 +82,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts } else { headerText = fmt.Sprintf("created %d todos", meta.Total) } - body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, width) + body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth) } else { // Build header based on what changed. hasCompleted := len(meta.JustCompleted) > 0 @@ -107,7 +108,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts // Build body with details. if allCompleted { // Show all todos when all are completed, like when created. - body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, width) + body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth) } else if meta.JustStarted != "" { body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") + sty.Base.Render(meta.JustStarted) @@ -118,12 +119,12 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts } toolParams := []string{headerText} - header := toolHeader(sty, opts.Status, "To-Do", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "To-Do", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } - if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { return joinToolParts(header, earlyState) } diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 07bf40f96b08c24907f0bd65d80cebfb74eae58b..f7702cc1fe516bb3dee7d57ce15fed050299019f 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -295,6 +295,9 @@ func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { // RawRender implements [MessageItem]. func (t *baseToolMessageItem) RawRender(width int) string { toolItemWidth := width - MessageLeftPaddingTotal + if t.hasCappedWidth { + toolItemWidth = cappedMessageWidth(width) + } content, height, ok := t.getCachedRender(toolItemWidth) // if we are spinning or there is no cache rerender @@ -770,6 +773,11 @@ func roundedEnumerator(lPadding, width int) tree.Enumerator { func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string { content = stringext.NormalizeSpace(content) + // Cap width for readability. + if width > maxTextWidth { + width = maxTextWidth + } + renderer := common.PlainMarkdownRenderer(sty, width) rendered, err := renderer.Render(content) if err != nil { diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go index 814a0270aad00bbf85c78629ffdfaf01a17c2e7f..91211590ce66dd0dd7edbde03becdf469e26b521 100644 --- a/internal/ui/chat/user.go +++ b/internal/ui/chat/user.go @@ -36,13 +36,15 @@ func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachment // RawRender implements [MessageItem]. func (m *UserMessageItem) RawRender(width int) string { - content, height, ok := m.getCachedRender(width) + cappedWidth := cappedMessageWidth(width) + + content, height, ok := m.getCachedRender(cappedWidth) // cache hit if ok { - return m.renderHighlighted(content, width, height) + return m.renderHighlighted(content, cappedWidth, height) } - renderer := common.MarkdownRenderer(m.sty, width) + renderer := common.MarkdownRenderer(m.sty, cappedWidth) msgContent := strings.TrimSpace(m.message.Content().Text) result, err := renderer.Render(msgContent) @@ -53,7 +55,7 @@ func (m *UserMessageItem) RawRender(width int) string { } if len(m.message.BinaryContent()) > 0 { - attachmentsStr := m.renderAttachments(width) + attachmentsStr := m.renderAttachments(cappedWidth) if content == "" { content = attachmentsStr } else { @@ -62,8 +64,8 @@ func (m *UserMessageItem) RawRender(width int) string { } height = lipgloss.Height(content) - m.setCachedRender(content, width, height) - return m.renderHighlighted(content, width, height) + m.setCachedRender(content, cappedWidth, height) + return m.renderHighlighted(content, cappedWidth, height) } // Render implements MessageItem. From f22a6f986a32e94bdd46672557bccf8544575859 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 5 Feb 2026 17:22:04 +0300 Subject: [PATCH 048/125] fix(ui): list: ensure the offset line does not go negative when scrolling up When scrolling up in the list, the offset line could become negative if there was a gap between items. This change ensures that the offset line is clamped to zero in such cases, preventing potential rendering issues. This also adds a check to avoid unnecessary scrolling when already at the bottom of the list. The calculation of item height has been simplified by using strings.Count directly. --- internal/ui/list/list.go | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index c3693494881c0a600f3d519471835789ebd54530..aec21715fcd924fde40ab9c41e9a4b6e65727ee8 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -158,7 +158,7 @@ func (l *List) getItem(idx int) renderedItem { rendered := item.Render(l.width) rendered = strings.TrimRight(rendered, "\n") - height := countLines(rendered) + height := strings.Count(rendered, "\n") + 1 ri := renderedItem{ content: rendered, height: height, @@ -190,13 +190,18 @@ func (l *List) ScrollBy(lines int) { } if lines > 0 { + if l.AtBottom() { + // Already at bottom + return + } + // Scroll down l.offsetLine += lines currentItem := l.getItem(l.offsetIdx) for l.offsetLine >= currentItem.height { l.offsetLine -= currentItem.height if l.gap > 0 { - l.offsetLine -= l.gap + l.offsetLine = max(0, l.offsetLine-l.gap) } // Move to next item @@ -219,14 +224,13 @@ func (l *List) ScrollBy(lines int) { // Scroll up l.offsetLine += lines // lines is negative for l.offsetLine < 0 { - if l.offsetIdx <= 0 { + // Move to previous item + l.offsetIdx-- + if l.offsetIdx < 0 { // Reached top l.ScrollToTop() break } - - // Move to previous item - l.offsetIdx-- prevItem := l.getItem(l.offsetIdx) totalHeight := prevItem.height if l.gap > 0 { @@ -642,11 +646,3 @@ func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) { return -1, -1 } - -// countLines counts the number of lines in a string. -func countLines(s string) int { - if s == "" { - return 1 - } - return strings.Count(s, "\n") + 1 -} From 3a9d95d82ebebd7605c7f377e1376c7d34540ff5 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 5 Feb 2026 14:22:15 -0300 Subject: [PATCH 050/125] fix(ui): use plain letters for lsp status (#2121) * fix(ui): use plain letters for lsp status symbols might be wrongly interpreted by some terminals, so this might be a good idea Signed-off-by: Carlos Alexandro Becker * chore(ui): use consts for LSP symbols * fix: missing icon usage Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker Co-authored-by: Christian Rocha --- internal/ui/common/elements.go | 2 +- internal/ui/dialog/api_key_input.go | 2 +- internal/ui/model/header.go | 2 +- internal/ui/model/lsp.go | 8 ++++---- internal/ui/styles/styles.go | 9 +++++---- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index 16fe528f736c8b40a16d664b47d1ea1e1f1ecb93..a129d1861e483c5c2064dc70d70ebd5c09cbd1f8 100644 --- a/internal/ui/common/elements.go +++ b/internal/ui/common/elements.go @@ -99,7 +99,7 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo formattedPercentage := t.Muted.Render(fmt.Sprintf("%d%%", int(percentage))) formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens) if percentage > 80 { - formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens) + formattedTokens = fmt.Sprintf("%s %s", styles.LSPWarningIcon, formattedTokens) } return fmt.Sprintf("%s %s", formattedTokens, formattedCost) diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index 6e9d26f0c5badc0d96bb6c6212ae3b69856b9e06..f06d9ff8f1af19d6dc04564bf82c8d523eee6525 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -256,7 +256,7 @@ func (m *APIKeyInput) inputView() string { ts := t.TextInput ts.Focused.Prompt = ts.Focused.Prompt.Foreground(charmtone.Cherry) - m.input.Prompt = styles.ErrorIcon + " " + m.input.Prompt = styles.LSPErrorIcon + " " m.input.SetStyles(ts) m.input.Focus() } diff --git a/internal/ui/model/header.go b/internal/ui/model/header.go index 5e704bf6ed8a5f69e224ceeca05d34ad59740789..3d576e85d022192bb8435909915b8d1d7c5a04ee 100644 --- a/internal/ui/model/header.go +++ b/internal/ui/model/header.go @@ -114,7 +114,7 @@ func renderHeaderDetails( } if errorCount > 0 { - parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount))) + parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, errorCount))) } agentCfg := config.Get().Agents[config.AgentCoder] diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go index c46beb10083b420ec1353c8a2536d45093a899b4..ef78ebfb2c4e069901e0b4433587e948f98643d1 100644 --- a/internal/ui/model/lsp.go +++ b/internal/ui/model/lsp.go @@ -62,16 +62,16 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string { func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string { errs := []string{} if diagnostics[protocol.SeverityError] > 0 { - errs = append(errs, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s %d", styles.ErrorIcon, diagnostics[protocol.SeverityError]))) + errs = append(errs, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, diagnostics[protocol.SeverityError]))) } if diagnostics[protocol.SeverityWarning] > 0 { - errs = append(errs, t.LSP.WarningDiagnostic.Render(fmt.Sprintf("%s %d", styles.WarningIcon, diagnostics[protocol.SeverityWarning]))) + errs = append(errs, t.LSP.WarningDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPWarningIcon, diagnostics[protocol.SeverityWarning]))) } if diagnostics[protocol.SeverityHint] > 0 { - errs = append(errs, t.LSP.HintDiagnostic.Render(fmt.Sprintf("%s %d", styles.HintIcon, diagnostics[protocol.SeverityHint]))) + errs = append(errs, t.LSP.HintDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPHintIcon, diagnostics[protocol.SeverityHint]))) } if diagnostics[protocol.SeverityInformation] > 0 { - errs = append(errs, t.LSP.InfoDiagnostic.Render(fmt.Sprintf("%s %d", styles.InfoIcon, diagnostics[protocol.SeverityInformation]))) + errs = append(errs, t.LSP.InfoDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPInfoIcon, diagnostics[protocol.SeverityInformation]))) } return strings.Join(errs, " ") } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index c1d548ec25a2afb0645927a22fa5821ba8c3b968..d28dd1b462ffa6e7bc6bc2c1a34b4ef66d513ef7 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -18,10 +18,6 @@ import ( const ( CheckIcon string = "✓" - ErrorIcon string = "×" - WarningIcon string = "⚠" - InfoIcon string = "ⓘ" - HintIcon string = "∵" SpinnerIcon string = "⋯" LoadingIcon string = "⟳" ModelIcon string = "◇" @@ -49,6 +45,11 @@ const ( ScrollbarThumb string = "┃" ScrollbarTrack string = "│" + + LSPErrorIcon string = "E" + LSPWarningIcon string = "W" + LSPInfoIcon string = "I" + LSPHintIcon string = "H" ) const ( From 989b0d28cefcd82f1575087d4b9ed8be9b742df0 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 5 Feb 2026 15:20:59 -0500 Subject: [PATCH 051/125] fix: cap posthog shutdown timeout (#2138) --- internal/event/event.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/event/event.go b/internal/event/event.go index 10b054ce0b21fb3c0db441746827a20739963315..389b6549e35323eef8dbe37ded671c5f33544adc 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -39,8 +39,9 @@ func SetNonInteractive(nonInteractive bool) { func Init() { c, err := posthog.NewWithConfig(key, posthog.Config{ - Endpoint: endpoint, - Logger: logger{}, + Endpoint: endpoint, + Logger: logger{}, + ShutdownTimeout: 500 * time.Millisecond, }) if err != nil { slog.Error("Failed to initialize PostHog client", "error", err) From 74aff5d4a19f390532b9739c99099443623b6c5d Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 5 Feb 2026 17:24:37 -0300 Subject: [PATCH 052/125] chore: update fantasy to v0.7.1 (#2139) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 34af0a4796c028995a22639695f1c3d03baf6059..1785e4bba17e39f215b9b628bff79343ec9026d4 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ 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.1 - charm.land/fantasy v0.7.0 + charm.land/fantasy v0.7.1 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 diff --git a/go.sum b/go.sum index 3d63891c57c31aa01ccaeacbaf55af1ab79fe45d..8c0c564195b69579fe7eac9b2df8543d8aea6c60 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8= 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/fantasy v0.7.1 h1:JOCYeLz32PM11y1u08/YgWl3LfPwhjOIuoyjBXjFofI= +charm.land/fantasy v0.7.1/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= From ef1ba40c6d4829d4a522b9d717f4e219d81fe646 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Fri, 6 Feb 2026 04:16:59 -0300 Subject: [PATCH 053/125] chore(legal): @francescoalemanno 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 ba3015dbbac51fb88f9b207b57708280885733de..e12f0c702c6587be7d98724543a2c0ae40c1e98a 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1199,6 +1199,14 @@ "created_at": "2026-02-05T10:17:46Z", "repoId": 987670088, "pullRequestNo": 2131 + }, + { + "name": "francescoalemanno", + "id": 50984334, + "comment_id": 3858464719, + "created_at": "2026-02-06T07:16:50Z", + "repoId": 987670088, + "pullRequestNo": 2142 } ] } \ No newline at end of file From 2c9670a890c216626e5f9212fe9cf7099525b2da Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 6 Feb 2026 11:57:59 +0100 Subject: [PATCH 054/125] refactor: remove global config (#2132) * refactor: remove global config from ui * refactor: remove global config from lsp * refactor: remove global config --- internal/agent/coordinator.go | 2 +- internal/agent/tools/mcp-tools.go | 7 +++++-- internal/agent/tools/mcp/init.go | 5 ++--- internal/agent/tools/mcp/prompts.go | 5 +++-- internal/agent/tools/mcp/tools.go | 15 +++++++-------- internal/app/lsp.go | 2 +- internal/cmd/login.go | 10 ++++------ internal/cmd/root.go | 6 +++--- internal/commands/commands.go | 4 ++-- internal/config/init.go | 25 ++++++++----------------- internal/lsp/client.go | 22 ++++++++++++---------- internal/lsp/client_test.go | 2 +- internal/lsp/handlers.go | 6 ------ internal/ui/chat/messages.go | 8 +++++--- internal/ui/dialog/api_key_input.go | 2 +- internal/ui/model/header.go | 4 ++-- internal/ui/model/onboarding.go | 2 +- internal/ui/model/ui.go | 12 ++++++------ 18 files changed, 64 insertions(+), 75 deletions(-) diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 09313f363d5d692971801354e0f5d609a20015ca..ad57b20c4470aa0180120798034db1bdb1de601a 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -446,7 +446,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan } } - for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) { + for _, tool := range tools.GetMCPTools(c.permissions, c.cfg, c.cfg.WorkingDir()) { if agent.AllowedMCP == nil { // No MCP restrictions filteredTools = append(filteredTools, tool) diff --git a/internal/agent/tools/mcp-tools.go b/internal/agent/tools/mcp-tools.go index fa55f03728639a09e6bd2f150338238d30120883..429cadaf6b686b83e170ef35976881d839b07e17 100644 --- a/internal/agent/tools/mcp-tools.go +++ b/internal/agent/tools/mcp-tools.go @@ -6,11 +6,12 @@ import ( "charm.land/fantasy" "github.com/charmbracelet/crush/internal/agent/tools/mcp" + "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/permission" ) // GetMCPTools gets all the currently available MCP tools. -func GetMCPTools(permissions permission.Service, wd string) []*Tool { +func GetMCPTools(permissions permission.Service, cfg *config.Config, wd string) []*Tool { var result []*Tool for mcpName, tools := range mcp.Tools() { for _, tool := range tools { @@ -19,6 +20,7 @@ func GetMCPTools(permissions permission.Service, wd string) []*Tool { tool: tool, permissions: permissions, workingDir: wd, + cfg: cfg, }) } } @@ -29,6 +31,7 @@ func GetMCPTools(permissions permission.Service, wd string) []*Tool { type Tool struct { mcpName string tool *mcp.Tool + cfg *config.Config permissions permission.Service workingDir string providerOptions fantasy.ProviderOptions @@ -107,7 +110,7 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } - result, err := mcp.RunTool(ctx, m.mcpName, m.tool.Name, params.Input) + result, err := mcp.RunTool(ctx, m.cfg, m.mcpName, m.tool.Name, params.Input) if err != nil { return fantasy.NewTextErrorResponse(err.Error()), nil } diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index c37f238e6d915d265153518b6df27f07bb6e456e..3138e07d57d96a25569a48dab5b79fb46f52759e 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -189,7 +189,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config return } - toolCount := updateTools(name, tools) + toolCount := updateTools(cfg, name, tools) updatePrompts(name, prompts) sessions.Set(name, session) @@ -214,13 +214,12 @@ func WaitForInit(ctx context.Context) error { } } -func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, error) { +func getOrRenewClient(ctx context.Context, cfg *config.Config, name string) (*mcp.ClientSession, error) { sess, ok := sessions.Get(name) if !ok { return nil, fmt.Errorf("mcp '%s' not available", name) } - cfg := config.Get() m := cfg.MCP[name] state, _ := states.Get(name) diff --git a/internal/agent/tools/mcp/prompts.go b/internal/agent/tools/mcp/prompts.go index ea208a57716d2a273fde1b6faa3988ca2e57b012..76338b4a8e349c9177ecaa216be217e241ec402d 100644 --- a/internal/agent/tools/mcp/prompts.go +++ b/internal/agent/tools/mcp/prompts.go @@ -5,6 +5,7 @@ import ( "iter" "log/slog" + "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -19,8 +20,8 @@ func Prompts() iter.Seq2[string, []*Prompt] { } // GetPromptMessages retrieves the content of an MCP prompt with the given arguments. -func GetPromptMessages(ctx context.Context, clientName, promptName string, args map[string]string) ([]string, error) { - c, err := getOrRenewClient(ctx, clientName) +func GetPromptMessages(ctx context.Context, cfg *config.Config, clientName, promptName string, args map[string]string) ([]string, error) { + c, err := getOrRenewClient(ctx, cfg, clientName) if err != nil { return nil, err } diff --git a/internal/agent/tools/mcp/tools.go b/internal/agent/tools/mcp/tools.go index 65ef5a9d8b3e7304a49bd708ecdd53a3cc400b17..da4b463bbc850ea8bfa0c3400defecf05507951d 100644 --- a/internal/agent/tools/mcp/tools.go +++ b/internal/agent/tools/mcp/tools.go @@ -32,13 +32,13 @@ func Tools() iter.Seq2[string, []*Tool] { } // RunTool runs an MCP tool with the given input parameters. -func RunTool(ctx context.Context, name, toolName string, input string) (ToolResult, error) { +func RunTool(ctx context.Context, cfg *config.Config, name, toolName string, input string) (ToolResult, error) { var args map[string]any if err := json.Unmarshal([]byte(input), &args); err != nil { return ToolResult{}, fmt.Errorf("error parsing parameters: %s", err) } - c, err := getOrRenewClient(ctx, name) + c, err := getOrRenewClient(ctx, cfg, name) if err != nil { return ToolResult{}, err } @@ -108,7 +108,7 @@ func RunTool(ctx context.Context, name, toolName string, input string) (ToolResu // RefreshTools gets the updated list of tools from the MCP and updates the // global state. -func RefreshTools(ctx context.Context, name string) { +func RefreshTools(ctx context.Context, cfg *config.Config, name string) { session, ok := sessions.Get(name) if !ok { slog.Warn("Refresh tools: no session", "name", name) @@ -121,7 +121,7 @@ func RefreshTools(ctx context.Context, name string) { return } - toolCount := updateTools(name, tools) + toolCount := updateTools(cfg, name, tools) prev, _ := states.Get(name) prev.Counts.Tools = toolCount @@ -139,8 +139,8 @@ func getTools(ctx context.Context, session *mcp.ClientSession) ([]*Tool, error) return result.Tools, nil } -func updateTools(name string, tools []*Tool) int { - tools = filterDisabledTools(name, tools) +func updateTools(cfg *config.Config, name string, tools []*Tool) int { + tools = filterDisabledTools(cfg, name, tools) if len(tools) == 0 { allTools.Del(name) return 0 @@ -150,8 +150,7 @@ func updateTools(name string, tools []*Tool) int { } // filterDisabledTools removes tools that are disabled via config. -func filterDisabledTools(mcpName string, tools []*Tool) []*Tool { - cfg := config.Get() +func filterDisabledTools(cfg *config.Config, mcpName string, tools []*Tool) []*Tool { mcpCfg, ok := cfg.MCP[mcpName] if !ok || len(mcpCfg.DisabledTools) == 0 { return tools diff --git a/internal/app/lsp.go b/internal/app/lsp.go index a93fadbd1869f46bb153e19fa15428f74293b7fc..2bb20fad3878a771ce8b6a2a4dc3688de44ba5dd 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -114,7 +114,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config updateLSPState(name, lsp.StateStarting, nil, nil, 0) // Create LSP client. - lspClient, err := lsp.New(ctx, name, config, app.config.Resolver()) + lspClient, err := lsp.New(ctx, name, config, app.config.Resolver(), app.config.Options.DebugLSP) if err != nil { if !userConfigured { slog.Warn("Default LSP config skipped due to error", "name", name, "error", err) diff --git a/internal/cmd/login.go b/internal/cmd/login.go index b38eaeed00ad1def862d83145f256bc219c27fda..bdad4547d6f583b5ae7e5a97bbbbd88a1421e6ee 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -52,17 +52,16 @@ crush login copilot } switch provider { case "hyper": - return loginHyper() + return loginHyper(app.Config()) case "copilot", "github", "github-copilot": - return loginCopilot() + return loginCopilot(app.Config()) default: return fmt.Errorf("unknown platform: %s", args[0]) } }, } -func loginHyper() error { - cfg := config.Get() +func loginHyper(cfg *config.Config) error { if !hyperp.Enabled() { return fmt.Errorf("hyper not enabled") } @@ -124,10 +123,9 @@ func loginHyper() error { return nil } -func loginCopilot() error { +func loginCopilot(cfg *config.Config) error { ctx := getLoginContext() - cfg := config.Get() if cfg.HasConfigField("providers.copilot.oauth") { fmt.Println("You are already logged in to GitHub Copilot.") return nil diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c6dac2e7801779e359c939e7d595323a8ac22e49..cf6fd0909ebfdf1643e2ad4fc2de868a8b1e1c1a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -227,21 +227,21 @@ func setupApp(cmd *cobra.Command) (*app.App, error) { return nil, err } - if shouldEnableMetrics() { + if shouldEnableMetrics(cfg) { event.Init() } return appInstance, nil } -func shouldEnableMetrics() bool { +func shouldEnableMetrics(cfg *config.Config) bool { if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v { return false } if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v { return false } - if config.Get().Options.DisableMetrics { + if cfg.Options.DisableMetrics { return false } return true diff --git a/internal/commands/commands.go b/internal/commands/commands.go index b3fd3915182fa293aefc1fe60ec54e5b369fa591..aeb2ca305dc984c2c450d249d51028858e4e9802 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -227,9 +227,9 @@ func isMarkdownFile(name string) bool { return strings.HasSuffix(strings.ToLower(name), ".md") } -func GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) { +func GetMCPPrompt(cfg *config.Config, clientID, promptID string, args map[string]string) (string, error) { // TODO: we should pass the context down - result, err := mcp.GetPromptMessages(context.Background(), clientID, promptID, args) + result, err := mcp.GetPromptMessages(context.Background(), cfg, clientID, promptID, args) if err != nil { return "", err } diff --git a/internal/config/init.go b/internal/config/init.go index 36742ed96ea91a1cb8834a2ddabfc8dfc8e56f38..5a4683f77485f54409d4372a33d1933b47abd33f 100644 --- a/internal/config/init.go +++ b/internal/config/init.go @@ -6,7 +6,6 @@ import ( "path/filepath" "slices" "strings" - "sync/atomic" "github.com/charmbracelet/crush/internal/fsext" ) @@ -19,25 +18,15 @@ type ProjectInitFlag struct { Initialized bool `json:"initialized"` } -// TODO: we need to remove the global config instance keeping it now just until everything is migrated -var instance atomic.Pointer[Config] - func Init(workingDir, dataDir string, debug bool) (*Config, error) { cfg, err := Load(workingDir, dataDir, debug) if err != nil { return nil, err } - instance.Store(cfg) - return instance.Load(), nil -} - -func Get() *Config { - cfg := instance.Load() - return cfg + return cfg, nil } -func ProjectNeedsInitialization() (bool, error) { - cfg := Get() +func ProjectNeedsInitialization(cfg *Config) (bool, error) { if cfg == nil { return false, fmt.Errorf("config not loaded") } @@ -110,8 +99,7 @@ func dirHasNoVisibleFiles(dir string) (bool, error) { return len(files) == 0, nil } -func MarkProjectInitialized() error { - cfg := Get() +func MarkProjectInitialized(cfg *Config) error { if cfg == nil { return fmt.Errorf("config not loaded") } @@ -126,10 +114,13 @@ func MarkProjectInitialized() error { return nil } -func HasInitialDataConfig() bool { +func HasInitialDataConfig(cfg *Config) bool { + if cfg == nil { + return false + } cfgPath := GlobalConfigData() if _, err := os.Stat(cfgPath); err != nil { return false } - return Get().IsConfigured() + return cfg.IsConfigured() } diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 6c0059250c062c01ab3d541f4b0ca55ebf0b0cb6..6420cec050e283b3061b2f87275606b4bf9720a1 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -35,6 +35,7 @@ type DiagnosticCounts struct { type Client struct { client *powernap.Client name string + debug bool // Working directory this LSP is scoped to. workDir string @@ -68,7 +69,7 @@ type Client struct { } // New creates a new LSP client using the powernap implementation. -func New(ctx context.Context, name string, cfg config.LSPConfig, resolver config.VariableResolver) (*Client, error) { +func New(ctx context.Context, name string, cfg config.LSPConfig, resolver config.VariableResolver, debug bool) (*Client, error) { client := &Client{ name: name, fileTypes: cfg.FileTypes, @@ -76,6 +77,7 @@ func New(ctx context.Context, name string, cfg config.LSPConfig, resolver config openFiles: csync.NewMap[string, *OpenFileInfo](), config: cfg, ctx: ctx, + debug: debug, resolver: resolver, } client.serverState.Store(StateStarting) @@ -174,7 +176,11 @@ func (c *Client) registerHandlers() { c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit) c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration) c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability) - c.RegisterNotificationHandler("window/showMessage", HandleServerMessage) + c.RegisterNotificationHandler("window/showMessage", func(ctx context.Context, method string, params json.RawMessage) { + if c.debug { + HandleServerMessage(ctx, method, params) + } + }) c.RegisterNotificationHandler("textDocument/publishDiagnostics", func(_ context.Context, _ string, params json.RawMessage) { HandleDiagnostics(c, params) }) @@ -262,8 +268,6 @@ func (c *Client) SetDiagnosticsCallback(callback func(name string, count int)) { // WaitForServerReady waits for the server to be ready func (c *Client) WaitForServerReady(ctx context.Context) error { - cfg := config.Get() - // Set initial state c.SetServerState(StateStarting) @@ -275,7 +279,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error { ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() - if cfg != nil && cfg.Options.DebugLSP { + if c.debug { slog.Debug("Waiting for LSP server to be ready...") } @@ -289,7 +293,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error { case <-ticker.C: // Check if client is running if !c.client.IsRunning() { - if cfg != nil && cfg.Options.DebugLSP { + if c.debug { slog.Debug("LSP server not ready yet", "server", c.name) } continue @@ -297,7 +301,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error { // Server is ready c.SetServerState(StateReady) - if cfg != nil && cfg.Options.DebugLSP { + if c.debug { slog.Debug("LSP server is ready") } return nil @@ -416,10 +420,8 @@ func (c *Client) IsFileOpen(filepath string) bool { // CloseAllFiles closes all currently open files. func (c *Client) CloseAllFiles(ctx context.Context) { - cfg := config.Get() - debugLSP := cfg != nil && cfg.Options.DebugLSP for uri := range c.openFiles.Seq2() { - if debugLSP { + if c.debug { slog.Debug("Closing file", "file", uri) } if err := c.client.NotifyDidCloseTextDocument(ctx, uri); err != nil { diff --git a/internal/lsp/client_test.go b/internal/lsp/client_test.go index 7cc9f2f4ba230a4c6896e7ccef367a450c1c55c7..1de51997f973909a616dc9b07283622b7839a3cb 100644 --- a/internal/lsp/client_test.go +++ b/internal/lsp/client_test.go @@ -23,7 +23,7 @@ func TestClient(t *testing.T) { // but we can still test the basic structure client, err := New(ctx, "test", cfg, config.NewEnvironmentVariableResolver(env.NewFromMap(map[string]string{ "THE_CMD": "echo", - }))) + })), false) if err != nil { // Expected to fail with echo command, skip the rest t.Skipf("Powernap client creation failed as expected with dummy command: %v", err) diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index b386e0780f6f6db6db13be380496c60a6e3c457e..9674ab22c226a4662beb08daa813325b52c079af 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -5,7 +5,6 @@ import ( "encoding/json" "log/slog" - "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/lsp/util" "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" ) @@ -80,11 +79,6 @@ func notifyFileWatchRegistration(id string, watchers []protocol.FileSystemWatche // HandleServerMessage handles server messages func HandleServerMessage(_ context.Context, method string, params json.RawMessage) { - cfg := config.Get() - if !cfg.Options.DebugLSP { - return - } - var msg protocol.ShowMessageParams if err := json.Unmarshal(params, &msg); err != nil { slog.Debug("Server message", "type", msg.Type, "message", msg.Message) diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 0c5668a20d52c5975dc63cb37da8090e9aa0ca7f..5dac49c08d32ae2315f9d8096f0410b2511ecb04 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -186,16 +186,18 @@ type AssistantInfoItem struct { id string message *message.Message sty *styles.Styles + cfg *config.Config lastUserMessageTime time.Time } // NewAssistantInfoItem creates a new AssistantInfoItem. -func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, lastUserMessageTime time.Time) MessageItem { +func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, cfg *config.Config, lastUserMessageTime time.Time) MessageItem { return &AssistantInfoItem{ cachedMessageItem: &cachedMessageItem{}, id: AssistantInfoID(message.ID), message: message, sty: sty, + cfg: cfg, lastUserMessageTime: lastUserMessageTime, } } @@ -231,13 +233,13 @@ func (a *AssistantInfoItem) renderContent(width int) string { duration := finishTime.Sub(a.lastUserMessageTime) infoMsg := a.sty.Chat.Message.AssistantInfoDuration.Render(duration.String()) icon := a.sty.Chat.Message.AssistantInfoIcon.Render(styles.ModelIcon) - model := config.Get().GetModel(a.message.Provider, a.message.Model) + model := a.cfg.GetModel(a.message.Provider, a.message.Model) if model == nil { model = &catwalk.Model{Name: "Unknown Model"} } modelFormatted := a.sty.Chat.Message.AssistantInfoModel.Render(model.Name) providerName := a.message.Provider - if providerConfig, ok := config.Get().Providers.Get(a.message.Provider); ok { + if providerConfig, ok := a.cfg.Providers.Get(a.message.Provider); ok { providerName = providerConfig.Name } provider := a.sty.Chat.Message.AssistantInfoProvider.Render(fmt.Sprintf("via %s", providerName)) diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index f06d9ff8f1af19d6dc04564bf82c8d523eee6525..9677763b2f4f2436376f5bf16ab58aed79140c68 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -296,7 +296,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg { Type: m.provider.Type, BaseURL: m.provider.APIEndpoint, } - err := providerConfig.TestConnection(config.Get().Resolver()) + err := providerConfig.TestConnection(m.com.Config().Resolver()) // intentionally wait for at least 750ms to make sure the user sees the spinner elapsed := time.Since(start) diff --git a/internal/ui/model/header.go b/internal/ui/model/header.go index 3d576e85d022192bb8435909915b8d1d7c5a04ee..2f6e093027783dca62f3d6cde12d61126c6061bb 100644 --- a/internal/ui/model/header.go +++ b/internal/ui/model/header.go @@ -117,8 +117,8 @@ func renderHeaderDetails( parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, errorCount))) } - agentCfg := config.Get().Agents[config.AgentCoder] - model := config.Get().GetModelByType(agentCfg.Model) + agentCfg := com.Config().Agents[config.AgentCoder] + model := com.Config().GetModelByType(agentCfg.Model) percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100 formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage))) parts = append(parts, formattedPercentage) diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go index 0a6ec0775b9f21da9bac4ed5ac2a7013457176a1..075067d75333fc539152f0041b4e5a3c2eed1c5e 100644 --- a/internal/ui/model/onboarding.go +++ b/internal/ui/model/onboarding.go @@ -19,7 +19,7 @@ import ( // markProjectInitialized marks the current project as initialized in the config. func (m *UI) markProjectInitialized() tea.Msg { // TODO: handle error so we show it in the tui footer - err := config.MarkProjectInitialized() + err := config.MarkProjectInitialized(m.com.Config()) if err != nil { slog.Error(err.Error()) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a01fafc2905f84e31fce9ce1914bdd8274e26ad4..7f4f01c5bdc2e7240716cc5c41a27892a4bcedde 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -298,7 +298,7 @@ func New(com *common.Common) *UI { desiredFocus := uiFocusEditor if !com.Config().IsConfigured() { desiredState = uiOnboarding - } else if n, _ := config.ProjectNeedsInitialization(); n { + } else if n, _ := config.ProjectNeedsInitialization(com.Config()); n { desiredState = uiInitialize } @@ -776,7 +776,7 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { case message.Assistant: items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...) if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { - infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, time.Unix(m.lastUserMessageTime, 0)) + infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0)) items = append(items, infoItem) } default: @@ -906,7 +906,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { } } if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { - infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0)) + infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0)) m.chat.AppendMessages(infoItem) if atBottom { if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { @@ -977,7 +977,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil { - newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0)) + newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0)) m.chat.AppendMessages(newInfoItem) } } @@ -1249,7 +1249,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { // Attempt to import GitHub Copilot tokens from VSCode if available. if isCopilot && !isConfigured() { - config.Get().ImportCopilot() + m.com.Config().ImportCopilot() } if !isConfigured() { @@ -3063,7 +3063,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) { func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd { load := func() tea.Msg { - prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments) + prompt, err := commands.GetMCPPrompt(m.com.Config(), clientID, promptID, arguments) if err != nil { // TODO: make this better return util.ReportError(err)() From ff212d6a0f09013a2b93af04b394317b6833ccd1 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:59:32 +0000 Subject: [PATCH 055/125] chore: auto-update files --- internal/agent/hyper/provider.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/agent/hyper/provider.json b/internal/agent/hyper/provider.json index 4f2cd461e46eeea6bb18739535a03003cb075f26..d10867bfc2568f826915074bbc7d2a0f99a42f6a 100644 --- a/internal/agent/hyper/provider.json +++ b/internal/agent/hyper/provider.json @@ -1 +1 @@ -{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://hyper.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 +{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://hyper.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-opus-4-6","name":"Claude Opus 4.6","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":126000,"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":256000,"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 From b66f96ea78b4cddc9aefc1c1e3094008419e3a0a Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 6 Feb 2026 09:52:18 -0300 Subject: [PATCH 056/125] feat(mcp): resources support (#2123) * feat(mcp): resources support Signed-off-by: Carlos Alexandro Becker * fix: log Signed-off-by: Carlos Alexandro Becker * fix: mcp events Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- internal/agent/coordinator.go | 8 + internal/agent/tools/list_mcp_resources.go | 105 ++++++++++++ internal/agent/tools/list_mcp_resources.md | 18 +++ internal/agent/tools/mcp/init.go | 44 ++++- internal/agent/tools/mcp/resources.go | 96 +++++++++++ internal/agent/tools/read_mcp_resource.go | 102 ++++++++++++ internal/agent/tools/read_mcp_resource.md | 20 +++ internal/config/config.go | 2 + internal/config/load_test.go | 4 +- internal/ui/completions/completions.go | 105 +++++++++--- internal/ui/completions/item.go | 8 + internal/ui/model/mcp.go | 5 +- internal/ui/model/ui.go | 179 ++++++++++++++++----- 13 files changed, 624 insertions(+), 72 deletions(-) create mode 100644 internal/agent/tools/list_mcp_resources.go create mode 100644 internal/agent/tools/list_mcp_resources.md create mode 100644 internal/agent/tools/mcp/resources.go create mode 100644 internal/agent/tools/read_mcp_resource.go create mode 100644 internal/agent/tools/read_mcp_resource.md diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index ad57b20c4470aa0180120798034db1bdb1de601a..d4e23af0c676307756f4e39fda7e10dfb2b6da5e 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -439,6 +439,14 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients)) } + if len(c.cfg.MCP) > 0 { + allTools = append( + allTools, + tools.NewListMCPResourcesTool(c.cfg, c.permissions), + tools.NewReadMCPResourceTool(c.cfg, c.permissions), + ) + } + var filteredTools []fantasy.AgentTool for _, tool := range allTools { if slices.Contains(agent.AllowedTools, tool.Info().Name) { diff --git a/internal/agent/tools/list_mcp_resources.go b/internal/agent/tools/list_mcp_resources.go new file mode 100644 index 0000000000000000000000000000000000000000..9b0417ed6343bc9680fbf8344f4a87a87bc2e015 --- /dev/null +++ b/internal/agent/tools/list_mcp_resources.go @@ -0,0 +1,105 @@ +package tools + +import ( + "cmp" + "context" + _ "embed" + "fmt" + "sort" + "strings" + + "charm.land/fantasy" + "github.com/charmbracelet/crush/internal/agent/tools/mcp" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/filepathext" + "github.com/charmbracelet/crush/internal/permission" +) + +type ListMCPResourcesParams struct { + MCPName string `json:"mcp_name" description:"The MCP server name"` +} + +type ListMCPResourcesPermissionsParams struct { + MCPName string `json:"mcp_name"` +} + +const ListMCPResourcesToolName = "list_mcp_resources" + +//go:embed list_mcp_resources.md +var listMCPResourcesDescription []byte + +func NewListMCPResourcesTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool { + return fantasy.NewParallelAgentTool( + ListMCPResourcesToolName, + string(listMCPResourcesDescription), + func(ctx context.Context, params ListMCPResourcesParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + params.MCPName = strings.TrimSpace(params.MCPName) + if params.MCPName == "" { + return fantasy.NewTextErrorResponse("mcp_name parameter is required"), nil + } + + sessionID := GetSessionFromContext(ctx) + if sessionID == "" { + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for listing MCP resources") + } + + relPath := filepathext.SmartJoin(cfg.WorkingDir(), cmp.Or(params.MCPName, "mcp-resources")) + p, err := permissions.Request(ctx, + permission.CreatePermissionRequest{ + SessionID: sessionID, + Path: relPath, + ToolCallID: call.ID, + ToolName: ListMCPResourcesToolName, + Action: "list", + Description: fmt.Sprintf("List MCP resources from %s", params.MCPName), + Params: ListMCPResourcesPermissionsParams(params), + }, + ) + if err != nil { + return fantasy.ToolResponse{}, err + } + if !p { + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied + } + + resources, err := mcp.ListResources(ctx, cfg, params.MCPName) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + if len(resources) == 0 { + return fantasy.NewTextResponse("No resources found"), nil + } + + lines := make([]string, 0, len(resources)) + for _, resource := range resources { + if resource == nil { + continue + } + title := resource.Title + if title == "" { + title = resource.Name + } + if title == "" { + title = resource.URI + } + line := fmt.Sprintf("- %s", title) + if resource.URI != "" { + line = fmt.Sprintf("%s (%s)", line, resource.URI) + } + if resource.Description != "" { + line = fmt.Sprintf("%s: %s", line, resource.Description) + } + if resource.MIMEType != "" { + line = fmt.Sprintf("%s [mime: %s]", line, resource.MIMEType) + } + if resource.Size > 0 { + line = fmt.Sprintf("%s [size: %d]", line, resource.Size) + } + lines = append(lines, line) + } + + sort.Strings(lines) + return fantasy.NewTextResponse(strings.Join(lines, "\n")), nil + }, + ) +} diff --git a/internal/agent/tools/list_mcp_resources.md b/internal/agent/tools/list_mcp_resources.md new file mode 100644 index 0000000000000000000000000000000000000000..ee2695d0d775b060696e11e80281ed4e2d3dd7c6 --- /dev/null +++ b/internal/agent/tools/list_mcp_resources.md @@ -0,0 +1,18 @@ +Lists available resources from an MCP server. + + +Use this tool to discover which resources are available before reading them. + + + +- Provide MCP server name +- Returns resource titles and URIs + + + +- mcp_name: The MCP server name + + + +- Results include resource titles, URIs, and metadata when available + diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index 3138e07d57d96a25569a48dab5b79fb46f52759e..7fdd2cd0a6477fce7a9dea85473e87e83d8e1a35 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -25,6 +25,19 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) +func parseLevel(level mcp.LoggingLevel) slog.Level { + switch level { + case "info": + return slog.LevelInfo + case "notice": + return slog.LevelInfo + case "warning": + return slog.LevelWarn + default: + return slog.LevelDebug + } +} + var ( sessions = csync.NewMap[string, *mcp.ClientSession]() states = csync.NewMap[string, ClientInfo]() @@ -65,6 +78,7 @@ const ( EventStateChanged EventType = iota EventToolsListChanged EventPromptsListChanged + EventResourcesListChanged ) // Event represents an event in the MCP system @@ -78,8 +92,9 @@ type Event struct { // Counts number of available tools, prompts, etc. type Counts struct { - Tools int - Prompts int + Tools int + Prompts int + Resources int } // ClientInfo holds information about an MCP client's state @@ -189,13 +204,23 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config return } + resources, err := getResources(ctx, session) + if err != nil { + slog.Error("Error listing resources", "error", err) + updateState(name, StateError, err, nil, Counts{}) + session.Close() + return + } + toolCount := updateTools(cfg, name, tools) updatePrompts(name, prompts) + resourceCount := updateResources(name, resources) sessions.Set(name, session) updateState(name, StateConnected, nil, session, Counts{ - Tools: toolCount, - Prompts: len(prompts), + Tools: toolCount, + Prompts: len(prompts), + Resources: resourceCount, }) }(name, m) } @@ -302,8 +327,15 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve Name: name, }) }, - LoggingMessageHandler: func(_ context.Context, req *mcp.LoggingMessageRequest) { - slog.Info("MCP log", "name", name, "data", req.Params.Data) + ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) { + broker.Publish(pubsub.UpdatedEvent, Event{ + Type: EventResourcesListChanged, + Name: name, + }) + }, + LoggingMessageHandler: func(ctx context.Context, req *mcp.LoggingMessageRequest) { + level := parseLevel(req.Params.Level) + slog.Log(ctx, level, "MCP log", "name", name, "logger", req.Params.Logger, "data", req.Params.Data) }, }, ) diff --git a/internal/agent/tools/mcp/resources.go b/internal/agent/tools/mcp/resources.go new file mode 100644 index 0000000000000000000000000000000000000000..92f6c83836181a8441d35431f900f5c68334a9eb --- /dev/null +++ b/internal/agent/tools/mcp/resources.go @@ -0,0 +1,96 @@ +package mcp + +import ( + "context" + "iter" + "log/slog" + + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/csync" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type Resource = mcp.Resource + +type ResourceContents = mcp.ResourceContents + +var allResources = csync.NewMap[string, []*Resource]() + +// Resources returns all available MCP resources. +func Resources() iter.Seq2[string, []*Resource] { + return allResources.Seq2() +} + +// ListResources returns the current resources for an MCP server. +func ListResources(ctx context.Context, cfg *config.Config, name string) ([]*Resource, error) { + session, err := getOrRenewClient(ctx, cfg, name) + if err != nil { + return nil, err + } + + resources, err := getResources(ctx, session) + if err != nil { + return nil, err + } + + resourceCount := updateResources(name, resources) + prev, _ := states.Get(name) + prev.Counts.Resources = resourceCount + updateState(name, StateConnected, nil, session, prev.Counts) + return resources, nil +} + +// ReadResource reads the contents of a resource from an MCP server. +func ReadResource(ctx context.Context, cfg *config.Config, name, uri string) ([]*ResourceContents, error) { + session, err := getOrRenewClient(ctx, cfg, name) + if err != nil { + return nil, err + } + result, err := session.ReadResource(ctx, &mcp.ReadResourceParams{URI: uri}) + if err != nil { + return nil, err + } + return result.Contents, nil +} + +// RefreshResources gets the updated list of resources from the MCP and updates the +// global state. +func RefreshResources(ctx context.Context, name string) { + session, ok := sessions.Get(name) + if !ok { + slog.Warn("Refresh resources: no session", "name", name) + return + } + + resources, err := getResources(ctx, session) + if err != nil { + updateState(name, StateError, err, nil, Counts{}) + return + } + + resourceCount := updateResources(name, resources) + + prev, _ := states.Get(name) + prev.Counts.Resources = resourceCount + updateState(name, StateConnected, nil, session, prev.Counts) +} + +func getResources(ctx context.Context, c *mcp.ClientSession) ([]*Resource, error) { + if c.InitializeResult().Capabilities.Resources == nil { + return nil, nil + } + result, err := c.ListResources(ctx, &mcp.ListResourcesParams{}) + if err != nil { + return nil, err + } + return result.Resources, nil +} + +func updateResources(name string, resources []*Resource) int { + if len(resources) == 0 { + allResources.Del(name) + return 0 + } + allResources.Set(name, resources) + return len(resources) +} diff --git a/internal/agent/tools/read_mcp_resource.go b/internal/agent/tools/read_mcp_resource.go new file mode 100644 index 0000000000000000000000000000000000000000..cc0450d63aa94574e45e4264906c77fc2b7a1127 --- /dev/null +++ b/internal/agent/tools/read_mcp_resource.go @@ -0,0 +1,102 @@ +package tools + +import ( + "cmp" + "context" + _ "embed" + "fmt" + "log/slog" + "strings" + + "charm.land/fantasy" + "github.com/charmbracelet/crush/internal/agent/tools/mcp" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/filepathext" + "github.com/charmbracelet/crush/internal/permission" +) + +type ReadMCPResourceParams struct { + MCPName string `json:"mcp_name" description:"The MCP server name"` + URI string `json:"uri" description:"The resource URI to read"` +} + +type ReadMCPResourcePermissionsParams struct { + MCPName string `json:"mcp_name"` + URI string `json:"uri"` +} + +const ReadMCPResourceToolName = "read_mcp_resource" + +//go:embed read_mcp_resource.md +var readMCPResourceDescription []byte + +func NewReadMCPResourceTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool { + return fantasy.NewParallelAgentTool( + ReadMCPResourceToolName, + string(readMCPResourceDescription), + func(ctx context.Context, params ReadMCPResourceParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + params.MCPName = strings.TrimSpace(params.MCPName) + params.URI = strings.TrimSpace(params.URI) + if params.MCPName == "" { + return fantasy.NewTextErrorResponse("mcp_name parameter is required"), nil + } + if params.URI == "" { + return fantasy.NewTextErrorResponse("uri parameter is required"), nil + } + + sessionID := GetSessionFromContext(ctx) + if sessionID == "" { + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for reading MCP resources") + } + + relPath := filepathext.SmartJoin(cfg.WorkingDir(), cmp.Or(params.URI, "mcp-resource")) + p, err := permissions.Request(ctx, + permission.CreatePermissionRequest{ + SessionID: sessionID, + Path: relPath, + ToolCallID: call.ID, + ToolName: ReadMCPResourceToolName, + Action: "read", + Description: fmt.Sprintf("Read MCP resource from %s", params.MCPName), + Params: ReadMCPResourcePermissionsParams(params), + }, + ) + if err != nil { + return fantasy.ToolResponse{}, err + } + if !p { + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied + } + + contents, err := mcp.ReadResource(ctx, cfg, params.MCPName, params.URI) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + if len(contents) == 0 { + return fantasy.NewTextResponse(""), nil + } + + var textParts []string + for _, content := range contents { + if content == nil { + continue + } + if content.Text != "" { + textParts = append(textParts, content.Text) + continue + } + if len(content.Blob) > 0 { + textParts = append(textParts, string(content.Blob)) + continue + } + slog.Debug("MCP resource content missing text/blob", "uri", content.URI) + } + + if len(textParts) == 0 { + return fantasy.NewTextResponse(""), nil + } + + return fantasy.NewTextResponse(strings.Join(textParts, "\n")), nil + }, + ) +} diff --git a/internal/agent/tools/read_mcp_resource.md b/internal/agent/tools/read_mcp_resource.md new file mode 100644 index 0000000000000000000000000000000000000000..72cb82bf22a926f1f21958d396359b54a73ec9c8 --- /dev/null +++ b/internal/agent/tools/read_mcp_resource.md @@ -0,0 +1,20 @@ +Reads a resource from an MCP server and returns its contents. + + +Use this tool to fetch a specific resource URI exposed by an MCP server. + + + +- Provide MCP server name and resource URI +- Returns resource text content + + + +- mcp_name: The MCP server name +- uri: The resource URI to read + + + +- Returns text content by concatenating resource parts +- Binary resources are returned as UTF-8 text when possible + diff --git a/internal/config/config.go b/internal/config/config.go index d5f3b8fb65b0d8d7f694fa3368d0263f4c3336a9..9a5f0eebfcd557f0ef71e5da471bc3348aeb5d55 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -710,6 +710,8 @@ func allToolNames() []string { "todos", "view", "write", + "list_mcp_resources", + "read_mcp_resource", } } diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 60a0b7379501a7d766b33c4828c644cdb390bada..0e23d22485c23e2b5e1c5fb1a98a86b561e00540 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -486,7 +486,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) { coderAgent, ok := cfg.Agents[AgentCoder] require.True(t, ok) - assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write"}, coderAgent.AllowedTools) + assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools) taskAgent, ok := cfg.Agents[AgentTask] require.True(t, ok) @@ -509,7 +509,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) { cfg.SetupAgents() coderAgent, ok := cfg.Agents[AgentCoder] require.True(t, ok) - assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write"}, coderAgent.AllowedTools) + assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools) taskAgent, ok := cfg.Agents[AgentTask] require.True(t, ok) diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go index a23ba5bf181f00856082b17aed8ef1ba5a816e93..ae130777a9278eb834f2eb544f630a4f51b8b212 100644 --- a/internal/ui/completions/completions.go +++ b/internal/ui/completions/completions.go @@ -1,12 +1,15 @@ package completions import ( + "cmp" "slices" "strings" + "sync" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/x/ansi" @@ -21,17 +24,18 @@ const ( ) // SelectionMsg is sent when a completion is selected. -type SelectionMsg struct { - Value any - Insert bool // If true, insert without closing. +type SelectionMsg[T any] struct { + Value T + KeepOpen bool // If true, insert without closing. } // ClosedMsg is sent when the completions are closed. type ClosedMsg struct{} -// FilesLoadedMsg is sent when files have been loaded for completions. -type FilesLoadedMsg struct { - Files []string +// CompletionItemsLoadedMsg is sent when files have been loaded for completions. +type CompletionItemsLoadedMsg struct { + Files []FileCompletionValue + Resources []ResourceCompletionValue } // Completions represents the completions popup component. @@ -92,23 +96,43 @@ func (c *Completions) KeyMap() KeyMap { return c.keyMap } -// OpenWithFiles opens the completions with file items from the filesystem. -func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd { +// Open opens the completions with file items from the filesystem. +func (c *Completions) Open(depth, limit int) tea.Cmd { return func() tea.Msg { - files, _, _ := fsext.ListDirectory(".", nil, depth, limit) - slices.Sort(files) - return FilesLoadedMsg{Files: files} + var msg CompletionItemsLoadedMsg + var wg sync.WaitGroup + wg.Go(func() { + msg.Files = loadFiles(depth, limit) + }) + wg.Go(func() { + msg.Resources = loadMCPResources() + }) + wg.Wait() + return msg } } -// SetFiles sets the file items on the completions popup. -func (c *Completions) SetFiles(files []string) { - items := make([]list.FilterableItem, 0, len(files)) +// SetItems sets the files and MCP resources and rebuilds the merged list. +func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) { + items := make([]list.FilterableItem, 0, len(files)+len(resources)) + + // Add files first. for _, file := range files { - file = strings.TrimPrefix(file, "./") item := NewCompletionItem( + file.Path, file, - FileCompletionValue{Path: file}, + c.normalStyle, + c.focusedStyle, + c.matchStyle, + ) + items = append(items, item) + } + + // Add MCP resources. + for _, resource := range resources { + item := NewCompletionItem( + resource.MCPName+"/"+cmp.Or(resource.Title, resource.URI), + resource, c.normalStyle, c.focusedStyle, c.matchStyle, @@ -119,7 +143,7 @@ func (c *Completions) SetFiles(files []string) { c.open = true c.query = "" c.list.SetItems(items...) - c.list.SetFilter("") // Clear any previous filter. + c.list.SetFilter("") c.list.Focus() c.width = maxWidth @@ -232,7 +256,7 @@ func (c *Completions) selectNext() { } // selectCurrent returns a command with the currently selected item. -func (c *Completions) selectCurrent(insert bool) tea.Msg { +func (c *Completions) selectCurrent(keepOpen bool) tea.Msg { items := c.list.FilteredItems() if len(items) == 0 { return nil @@ -248,13 +272,23 @@ func (c *Completions) selectCurrent(insert bool) tea.Msg { return nil } - if !insert { + if !keepOpen { c.open = false } - return SelectionMsg{ - Value: item.Value(), - Insert: insert, + switch item := item.Value().(type) { + case ResourceCompletionValue: + return SelectionMsg[ResourceCompletionValue]{ + Value: item, + KeepOpen: keepOpen, + } + case FileCompletionValue: + return SelectionMsg[FileCompletionValue]{ + Value: item, + KeepOpen: keepOpen, + } + default: + return nil } } @@ -271,3 +305,30 @@ func (c *Completions) Render() string { return c.list.Render() } + +func loadFiles(depth, limit int) []FileCompletionValue { + files, _, _ := fsext.ListDirectory(".", nil, depth, limit) + slices.Sort(files) + result := make([]FileCompletionValue, 0, len(files)) + for _, file := range files { + result = append(result, FileCompletionValue{ + Path: strings.TrimPrefix(file, "./"), + }) + } + return result +} + +func loadMCPResources() []ResourceCompletionValue { + var resources []ResourceCompletionValue + for mcpName, mcpResources := range mcp.Resources() { + for _, r := range mcpResources { + resources = append(resources, ResourceCompletionValue{ + MCPName: mcpName, + URI: r.URI, + Title: r.Name, + MIMEType: r.MIMEType, + }) + } + } + return resources +} diff --git a/internal/ui/completions/item.go b/internal/ui/completions/item.go index 1114083fd1a118649921ead3ea2288d6e6085632..3e99408dcc8e04288d5775dc01e17bcdd42a59a4 100644 --- a/internal/ui/completions/item.go +++ b/internal/ui/completions/item.go @@ -13,6 +13,14 @@ type FileCompletionValue struct { Path string } +// ResourceCompletionValue represents a MCP resource completion value. +type ResourceCompletionValue struct { + MCPName string + URI string + Title string + MIMEType string +} + // CompletionItem represents an item in the completions list. type CompletionItem struct { text string diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go index 40be8619133268edbc53cf2bee863ed89a2af00f..3345841618f0fdb6663fec80eb7784b1297c329c 100644 --- a/internal/ui/model/mcp.go +++ b/internal/ui/model/mcp.go @@ -34,7 +34,7 @@ func (m *UI) mcpInfo(width, maxItems int, isSection bool) string { return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) } -// mcpCounts formats tool and prompt counts for display. +// mcpCounts formats tool, prompt, and resource counts for display. func mcpCounts(t *styles.Styles, counts mcp.Counts) string { parts := []string{} if counts.Tools > 0 { @@ -43,6 +43,9 @@ func mcpCounts(t *styles.Styles, counts mcp.Counts) string { if counts.Prompts > 0 { parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d prompts", counts.Prompts))) } + if counts.Resources > 0 { + parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d resources", counts.Resources))) + } return strings.Join(parts, " ") } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 7f4f01c5bdc2e7240716cc5c41a27892a4bcedde..ad2944ab8e255797a4aedc8a3035019c1788bf89 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -354,18 +354,16 @@ func (m *UI) loadCustomCommands() tea.Cmd { } // loadMCPrompts loads the MCP prompts asynchronously. -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) - } - if prompts == nil { - // flag them as loaded even if there is none or an error - prompts = []commands.MCPPrompt{} - } - return mcpPromptsLoadedMsg{Prompts: prompts} +func (m *UI) loadMCPrompts() tea.Msg { + prompts, err := commands.LoadMCPPrompts() + if err != nil { + slog.Error("Failed to load MCP prompts", "error", err) } + if prompts == nil { + // flag them as loaded even if there is none or an error + prompts = []commands.MCPPrompt{} + } + return mcpPromptsLoadedMsg{Prompts: prompts} } // Update handles updates to the UI model. @@ -505,17 +503,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case pubsub.Event[app.LSPEvent]: m.lspStates = app.GetLSPStates() case pubsub.Event[mcp.Event]: - m.mcpStates = mcp.GetStates() - // check if all mcps are initialized - initialized := true - for _, state := range m.mcpStates { - if state.State == mcp.StateStarting { - initialized = false - break - } - } - if initialized && m.mcpPrompts == nil { - cmds = append(cmds, m.loadMCPrompts()) + switch msg.Payload.Type { + case mcp.EventStateChanged: + return m, tea.Batch( + m.handleStateChanged(), + m.loadMCPrompts, + ) + case mcp.EventPromptsListChanged: + return m, handleMCPPromptsEvent(msg.Payload.Name) + case mcp.EventToolsListChanged: + return m, handleMCPToolsEvent(m.com.Config(), msg.Payload.Name) + case mcp.EventResourcesListChanged: + return m, handleMCPResourcesEvent(msg.Payload.Name) } case pubsub.Event[permission.PermissionRequest]: if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil { @@ -713,10 +712,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, clearInfoMsgCmd(ttl)) case util.ClearStatusMsg: m.status.ClearInfoMsg() - case completions.FilesLoadedMsg: - // Handle async file loading for completions. + case completions.CompletionItemsLoadedMsg: if m.completionsOpen { - m.completions.SetFiles(msg.Files) + m.completions.SetItems(msg.Files, msg.Resources) } case uv.KittyGraphicsEvent: if !bytes.HasPrefix(msg.Payload, []byte("OK")) { @@ -1517,12 +1515,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { if m.completionsOpen { if msg, ok := m.completions.Update(msg); ok { switch msg := msg.(type) { - case completions.SelectionMsg: - // Handle file completion selection. - if item, ok := msg.Value.(completions.FileCompletionValue); ok { - cmds = append(cmds, m.insertFileCompletion(item.Path)) + case completions.SelectionMsg[completions.FileCompletionValue]: + cmds = append(cmds, m.insertFileCompletion(msg.Value.Path)) + if !msg.KeepOpen { + m.closeCompletions() } - if !msg.Insert { + case completions.SelectionMsg[completions.ResourceCompletionValue]: + cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value)) + if !msg.KeepOpen { m.closeCompletions() } case completions.ClosedMsg: @@ -1636,7 +1636,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.completionsStartIndex = curIdx m.completionsPositionStart = m.completionsPosition() depth, limit := m.com.Config().Options.TUI.Completions.Limits() - cmds = append(cmds, m.completions.OpenWithFiles(depth, limit)) + cmds = append(cmds, m.completions.Open(depth, limit)) } } @@ -2475,24 +2475,29 @@ func (m *UI) closeCompletions() { m.completions.Close() } -// insertFileCompletion inserts the selected file path into the textarea, -// replacing the @query, and adds the file as an attachment. -func (m *UI) insertFileCompletion(path string) tea.Cmd { +// insertCompletionText replaces the @query in the textarea with the given text. +// Returns false if the replacement cannot be performed. +func (m *UI) insertCompletionText(text string) bool { value := m.textarea.Value() - word := m.textareaWord() - - // Find the @ and query to replace. if m.completionsStartIndex > len(value) { - return nil + return false } - // Build the new value: everything before @, the path, everything after query. + word := m.textareaWord() endIdx := min(m.completionsStartIndex+len(word), len(value)) - - newValue := value[:m.completionsStartIndex] + path + value[endIdx:] + newValue := value[:m.completionsStartIndex] + text + value[endIdx:] m.textarea.SetValue(newValue) m.textarea.MoveToEnd() m.textarea.InsertRune(' ') + return true +} + +// insertFileCompletion inserts the selected file path into the textarea, +// replacing the @query, and adds the file as an attachment. +func (m *UI) insertFileCompletion(path string) tea.Cmd { + if !m.insertCompletionText(path) { + return nil + } return func() tea.Msg { absPath, _ := filepath.Abs(path) @@ -2527,6 +2532,61 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd { } } +// insertMCPResourceCompletion inserts the selected resource into the textarea, +// replacing the @query, and adds the resource as an attachment. +func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd { + displayText := item.Title + if displayText == "" { + displayText = item.URI + } + + if !m.insertCompletionText(displayText) { + return nil + } + + return func() tea.Msg { + contents, err := mcp.ReadResource( + context.Background(), + m.com.Config(), + item.MCPName, + item.URI, + ) + if err != nil { + slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err) + return nil + } + if len(contents) == 0 { + return nil + } + + content := contents[0] + var data []byte + if content.Text != "" { + data = []byte(content.Text) + } else if len(content.Blob) > 0 { + data = content.Blob + } + if len(data) == 0 { + return nil + } + + mimeType := item.MIMEType + if mimeType == "" && content.MIMEType != "" { + mimeType = content.MIMEType + } + if mimeType == "" { + mimeType = "text/plain" + } + + return message.Attachment{ + FilePath: item.URI, + FileName: displayText, + MimeType: mimeType, + Content: data, + } + } +} + // completionsPosition returns the X and Y position for the completions popup. func (m *UI) completionsPosition() image.Point { cur := m.textarea.Cursor() @@ -3088,6 +3148,43 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string return tea.Sequence(cmds...) } +func (m *UI) handleStateChanged() tea.Cmd { + slog.Warn("handleStateChanged") + return func() tea.Msg { + m.com.App.UpdateAgentModel(context.Background()) + m.mcpStates = mcp.GetStates() + return nil + } +} + +func handleMCPPromptsEvent(name string) tea.Cmd { + slog.Warn("handleMCPPromptsEvent") + return func() tea.Msg { + mcp.RefreshPrompts(context.Background(), name) + return nil + } +} + +func handleMCPToolsEvent(cfg *config.Config, name string) tea.Cmd { + slog.Warn("handleMCPToolsEvent") + return func() tea.Msg { + mcp.RefreshTools( + context.Background(), + cfg, + name, + ) + return nil + } +} + +func handleMCPResourcesEvent(name string) tea.Cmd { + slog.Warn("handleMCPResourcesEvent") + return func() tea.Msg { + mcp.RefreshResources(context.Background(), name) + return nil + } +} + func (m *UI) copyChatHighlight() tea.Cmd { text := m.chat.HighlightContent() return common.CopyToClipboardWithCallback( From 775cf3ae2609aa95ad1963a68b4a960c331371e1 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 6 Feb 2026 10:51:46 -0300 Subject: [PATCH 057/125] fix(mcp): race condition, logs (#2145) Signed-off-by: Carlos Alexandro Becker --- internal/agent/tools/list_mcp_resources.go | 3 +-- internal/ui/model/ui.go | 16 ++++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/internal/agent/tools/list_mcp_resources.go b/internal/agent/tools/list_mcp_resources.go index 9b0417ed6343bc9680fbf8344f4a87a87bc2e015..25671ffe481a21a82c40167c40614603e907052c 100644 --- a/internal/agent/tools/list_mcp_resources.go +++ b/internal/agent/tools/list_mcp_resources.go @@ -1,7 +1,6 @@ package tools import ( - "cmp" "context" _ "embed" "fmt" @@ -43,7 +42,7 @@ func NewListMCPResourcesTool(cfg *config.Config, permissions permission.Service) return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for listing MCP resources") } - relPath := filepathext.SmartJoin(cfg.WorkingDir(), cmp.Or(params.MCPName, "mcp-resources")) + relPath := filepathext.SmartJoin(cfg.WorkingDir(), params.MCPName) p, err := permissions.Request(ctx, permission.CreatePermissionRequest{ SessionID: sessionID, diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index ad2944ab8e255797a4aedc8a3035019c1788bf89..a8719f0fd041e56847bbbbf4d46b8846b5e520a4 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -97,6 +97,10 @@ type ( mcpPromptsLoadedMsg struct { Prompts []commands.MCPPrompt } + // mcpStateChangedMsg is sent when there is a change in MCP client states. + mcpStateChangedMsg struct { + states map[string]mcp.ClientInfo + } // sendMessageMsg is sent to send a message. // currently only used for mcp prompts. sendMessageMsg struct { @@ -429,6 +433,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if ok { commands.SetCustomCommands(m.customCommands) } + + case mcpStateChangedMsg: + m.mcpStates = msg.states case mcpPromptsLoadedMsg: m.mcpPrompts = msg.Prompts dia := m.dialog.Dialog(dialog.CommandsID) @@ -3149,16 +3156,15 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string } func (m *UI) handleStateChanged() tea.Cmd { - slog.Warn("handleStateChanged") return func() tea.Msg { m.com.App.UpdateAgentModel(context.Background()) - m.mcpStates = mcp.GetStates() - return nil + return mcpStateChangedMsg{ + states: mcp.GetStates(), + } } } func handleMCPPromptsEvent(name string) tea.Cmd { - slog.Warn("handleMCPPromptsEvent") return func() tea.Msg { mcp.RefreshPrompts(context.Background(), name) return nil @@ -3166,7 +3172,6 @@ func handleMCPPromptsEvent(name string) tea.Cmd { } func handleMCPToolsEvent(cfg *config.Config, name string) tea.Cmd { - slog.Warn("handleMCPToolsEvent") return func() tea.Msg { mcp.RefreshTools( context.Background(), @@ -3178,7 +3183,6 @@ func handleMCPToolsEvent(cfg *config.Config, name string) tea.Cmd { } func handleMCPResourcesEvent(name string) tea.Cmd { - slog.Warn("handleMCPResourcesEvent") return func() tea.Msg { mcp.RefreshResources(context.Background(), name) return nil From fc9db0563b86da23e22a227bac2e801f0c82d5c9 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 6 Feb 2026 15:54:51 -0300 Subject: [PATCH 058/125] feat: add clipboard image paste support (ctrl+v) (#2148) * feat: add clipboard image paste support (ctrl+v) Port of #1151 to the new UI. Assisted-by: Claude Opus 4.6 via Crush * fix: simplify Signed-off-by: Carlos Alexandro Becker * fix: go mod tidy Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- go.mod | 2 + go.sum | 4 ++ internal/ui/model/keys.go | 5 +++ internal/ui/model/ui.go | 80 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/go.mod b/go.mod index 1785e4bba17e39f215b9b628bff79343ec9026d4..daca23284eb1b3e6280210d13f6fb852371b9caf 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/PuerkitoBio/goquery v1.11.0 github.com/alecthomas/chroma/v2 v2.23.1 github.com/atotto/clipboard v0.1.4 + github.com/aymanbagabas/go-nativeclipboard v0.1.2 github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charlievieth/fastwalk v1.0.14 @@ -104,6 +105,7 @@ require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect diff --git a/go.sum b/go.sum index 8c0c564195b69579fe7eac9b2df8543d8aea6c60..f7248662cf8cb5300e0977553c86b88420d3e5e2 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aymanbagabas/go-nativeclipboard v0.1.2 h1:Z2iVRWQ4IynMLWM6a+lWH2Nk5gPyEtPRMuBIyZ2dECM= +github.com/aymanbagabas/go-nativeclipboard v0.1.2/go.mod h1:BVJhN7hs5DieCzUB2Atf4Yk9Y9kFe62E95+gOjpJq6Q= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -150,6 +152,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA= +github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index a42b1e7aa0ac9ac474de626b55ceb3a91824cdff..2018c0b644c7d68092c7f4bf990f0bb5c119c28e 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -9,6 +9,7 @@ type KeyMap struct { OpenEditor key.Binding Newline key.Binding AddImage key.Binding + PasteImage key.Binding MentionFile key.Binding Commands key.Binding @@ -120,6 +121,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("ctrl+f"), key.WithHelp("ctrl+f", "add image"), ) + km.Editor.PasteImage = key.NewBinding( + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "paste image from clipboard"), + ) km.Editor.MentionFile = key.NewBinding( key.WithKeys("@"), key.WithHelp("@", "mention file"), diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a8719f0fd041e56847bbbbf4d46b8846b5e520a4..05fa503eb40b60560fb6cf185a16ec5342144349 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -24,6 +24,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/catwalk/pkg/catwalk" "charm.land/lipgloss/v2" + nativeclipboard "github.com/aymanbagabas/go-nativeclipboard" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/commands" @@ -1549,6 +1550,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { cmds = append(cmds, cmd) } + case key.Matches(msg, m.keyMap.Editor.PasteImage): + cmds = append(cmds, m.pasteImageFromClipboard) + case key.Matches(msg, m.keyMap.Editor.SendMessage): value := m.textarea.Value() if before, ok := strings.CutSuffix(value, "\\"); ok { @@ -2081,6 +2085,7 @@ func (m *UI) FullHelp() [][]key.Binding { []key.Binding{ k.Editor.Newline, k.Editor.AddImage, + k.Editor.PasteImage, k.Editor.MentionFile, k.Editor.OpenEditor, }, @@ -2129,6 +2134,7 @@ func (m *UI) FullHelp() [][]key.Binding { []key.Binding{ k.Editor.Newline, k.Editor.AddImage, + k.Editor.PasteImage, k.Editor.MentionFile, k.Editor.OpenEditor, }, @@ -3061,6 +3067,80 @@ func (m *UI) handleFilePathPaste(path string) tea.Cmd { } } +// pasteImageFromClipboard reads image data from the system clipboard and +// creates an attachment. If no image data is found, it falls back to +// interpreting clipboard text as a file path. +func (m *UI) pasteImageFromClipboard() tea.Msg { + imageData, err := nativeclipboard.Image.Read() + if int64(len(imageData)) > common.MaxAttachmentSize { + return util.InfoMsg{ + Type: util.InfoTypeError, + Msg: "File too large, max 5MB", + } + } + name := fmt.Sprintf("paste_%d.png", m.pasteIdx()) + if err == nil { + return message.Attachment{ + FilePath: name, + FileName: name, + MimeType: mimeOf(imageData), + Content: imageData, + } + } + + textData, textErr := nativeclipboard.Text.Read() + if textErr != nil || len(textData) == 0 { + return util.NewInfoMsg("Clipboard is empty or does not contain an image") + } + + path := strings.TrimSpace(string(textData)) + path = strings.ReplaceAll(path, "\\ ", " ") + if _, statErr := os.Stat(path); statErr != nil { + return util.NewInfoMsg("Clipboard does not contain an image or valid file path") + } + + lowerPath := strings.ToLower(path) + isAllowed := false + for _, ext := range common.AllowedImageTypes { + if strings.HasSuffix(lowerPath, ext) { + isAllowed = true + break + } + } + if !isAllowed { + return util.NewInfoMsg("File type is not a supported image format") + } + + fileInfo, statErr := os.Stat(path) + if statErr != nil { + return util.InfoMsg{ + Type: util.InfoTypeError, + Msg: fmt.Sprintf("Unable to read file: %v", statErr), + } + } + if fileInfo.Size() > common.MaxAttachmentSize { + return util.InfoMsg{ + Type: util.InfoTypeError, + Msg: "File too large, max 5MB", + } + } + + content, readErr := os.ReadFile(path) + if readErr != nil { + return util.InfoMsg{ + Type: util.InfoTypeError, + Msg: fmt.Sprintf("Unable to read file: %v", readErr), + } + } + + return message.Attachment{ + FilePath: path, + FileName: filepath.Base(path), + MimeType: mimeOf(content), + Content: content, + } +} + var pasteRE = regexp.MustCompile(`paste_(\d+).txt`) func (m *UI) pasteIdx() int { From 0491b670e4eefde474876dbb7b699ed2d8b9e318 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 6 Feb 2026 15:55:04 -0300 Subject: [PATCH 059/125] perf: timer leak in setupSubscriber (#2147) * fix: fix timer leak in setupSubscriber causing resource exhaustion under high load * fix: synctest Signed-off-by: Carlos Alexandro Becker * test: improvements Signed-off-by: Carlos Alexandro Becker * refactor: tests Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker Co-authored-by: AnyCPU --- go.mod | 1 + go.sum | 2 + internal/app/app.go | 16 +++- internal/app/app_test.go | 157 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 internal/app/app_test.go diff --git a/go.mod b/go.mod index daca23284eb1b3e6280210d13f6fb852371b9caf..bda063569bac9702747085c90f9e57ec93593a06 100644 --- a/go.mod +++ b/go.mod @@ -60,6 +60,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/zeebo/xxh3 v1.1.0 + go.uber.org/goleak v1.3.0 golang.org/x/net v0.49.0 golang.org/x/sync v0.19.0 golang.org/x/text v0.33.0 diff --git a/go.sum b/go.sum index f7248662cf8cb5300e0977553c86b88420d3e5e2..5d5d296dbc94806e3d06a24190949548668671bd 100644 --- a/go.sum +++ b/go.sum @@ -375,6 +375,8 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= 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/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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= diff --git a/internal/app/app.go b/internal/app/app.go index f0cabfa534a58401280fb5e9b973aa6f5a9d91c9..35534629f64e29dc39beb95c55e5873b551218a4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -421,6 +421,8 @@ func (app *App) setupEvents() { app.cleanupFuncs = append(app.cleanupFuncs, cleanupFunc) } +const subscriberSendTimeout = 2 * time.Second + func setupSubscriber[T any]( ctx context.Context, wg *sync.WaitGroup, @@ -430,6 +432,10 @@ func setupSubscriber[T any]( ) { wg.Go(func() { subCh := subscriber(ctx) + sendTimer := time.NewTimer(0) + <-sendTimer.C + defer sendTimer.Stop() + for { select { case event, ok := <-subCh: @@ -438,9 +444,17 @@ func setupSubscriber[T any]( return } var msg tea.Msg = event + if !sendTimer.Stop() { + select { + case <-sendTimer.C: + default: + } + } + sendTimer.Reset(subscriberSendTimeout) + select { case outputCh <- msg: - case <-time.After(2 * time.Second): + case <-sendTimer.C: slog.Debug("Message dropped due to slow consumer", "name", name) case <-ctx.Done(): slog.Debug("Subscription cancelled", "name", name) diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000000000000000000000000000000000000..61b99158f9979d7e21a3c9fe7ad19c74a8111242 --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,157 @@ +package app + +import ( + "context" + "sync" + "testing" + "testing/synctest" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/pubsub" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +func TestSetupSubscriber_NormalFlow(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + f := newSubscriberFixture(t, 10) + + time.Sleep(10 * time.Millisecond) + synctest.Wait() + + f.broker.Publish(pubsub.CreatedEvent, "event1") + f.broker.Publish(pubsub.CreatedEvent, "event2") + + for range 2 { + select { + case <-f.outputCh: + case <-time.After(5 * time.Second): + t.Fatal("Timed out waiting for messages") + } + } + + f.cancel() + f.wg.Wait() + }) +} + +func TestSetupSubscriber_SlowConsumer(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + f := newSubscriberFixture(t, 0) + + const numEvents = 5 + + var pubWg sync.WaitGroup + pubWg.Go(func() { + for range numEvents { + f.broker.Publish(pubsub.CreatedEvent, "event") + time.Sleep(10 * time.Millisecond) + synctest.Wait() + } + }) + + time.Sleep(time.Duration(numEvents) * (subscriberSendTimeout + 20*time.Millisecond)) + synctest.Wait() + + received := 0 + for { + select { + case <-f.outputCh: + received++ + default: + pubWg.Wait() + f.cancel() + f.wg.Wait() + require.Less(t, received, numEvents, "Slow consumer should have dropped some messages") + return + } + } + }) +} + +func TestSetupSubscriber_ContextCancellation(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + f := newSubscriberFixture(t, 10) + + f.broker.Publish(pubsub.CreatedEvent, "event1") + time.Sleep(100 * time.Millisecond) + synctest.Wait() + + f.cancel() + f.wg.Wait() + }) +} + +func TestSetupSubscriber_DrainAfterDrop(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + f := newSubscriberFixture(t, 0) + + time.Sleep(10 * time.Millisecond) + synctest.Wait() + + // First event: nobody reads outputCh so the timer fires (message dropped). + f.broker.Publish(pubsub.CreatedEvent, "event1") + time.Sleep(subscriberSendTimeout + 25*time.Millisecond) + synctest.Wait() + + // Second event: triggers Stop()==false path; without the fix this deadlocks. + f.broker.Publish(pubsub.CreatedEvent, "event2") + + // If the timer drain deadlocks, wg.Wait never returns. + done := make(chan struct{}) + go func() { + f.cancel() + f.wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("setupSubscriber goroutine hung — likely timer drain deadlock") + } + }) +} + +func TestSetupSubscriber_NoTimerLeak(t *testing.T) { + defer goleak.VerifyNone(t) + synctest.Test(t, func(t *testing.T) { + f := newSubscriberFixture(t, 100) + + for range 100 { + f.broker.Publish(pubsub.CreatedEvent, "event") + time.Sleep(5 * time.Millisecond) + synctest.Wait() + } + + f.cancel() + f.wg.Wait() + }) +} + +type subscriberFixture struct { + broker *pubsub.Broker[string] + wg sync.WaitGroup + outputCh chan tea.Msg + cancel context.CancelFunc +} + +func newSubscriberFixture(t *testing.T, bufSize int) *subscriberFixture { + t.Helper() + ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(cancel) + + f := &subscriberFixture{ + broker: pubsub.NewBroker[string](), + outputCh: make(chan tea.Msg, bufSize), + cancel: cancel, + } + t.Cleanup(f.broker.Shutdown) + + setupSubscriber(ctx, &f.wg, "test", func(ctx context.Context) <-chan pubsub.Event[string] { + return f.broker.Subscribe(ctx) + }, f.outputCh) + + return f +} From 02f66a2e8614a6c029260bea7d3ca1565786915a Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 6 Feb 2026 16:12:18 -0300 Subject: [PATCH 060/125] feat(lsp): start LSPs on demand, improve auto-start (#2103) * feat(lsp): start LSPs on demand, improve auto-start Signed-off-by: Carlos Alexandro Becker * refactor: simplify Signed-off-by: Carlos Alexandro Becker * chore: fmt Signed-off-by: Carlos Alexandro Becker * feat: handle new session/load session Signed-off-by: Carlos Alexandro Becker * refactor: manager holds client list reference Signed-off-by: Carlos Alexandro Becker * fix: do io in cmd Signed-off-by: Carlos Alexandro Becker * fix: better manager Signed-off-by: Carlos Alexandro Becker * fix: nil Signed-off-by: Carlos Alexandro Becker * fix: err Signed-off-by: Carlos Alexandro Becker * fix: properly handle restart Signed-off-by: Carlos Alexandro Becker * fix: add stopped state Signed-off-by: Carlos Alexandro Becker * fix: root markers Signed-off-by: Carlos Alexandro Becker * fix: load for read files as well Signed-off-by: Carlos Alexandro Becker * chore(go.mod): move indirect dependency to the right block --------- Signed-off-by: Carlos Alexandro Becker Co-authored-by: Andrey Nering --- go.mod | 2 +- internal/agent/agentic_fetch_tool.go | 2 +- internal/agent/common_test.go | 8 +- internal/agent/coordinator.go | 20 +- internal/agent/tools/diagnostics.go | 32 +++- internal/agent/tools/edit.go | 7 +- internal/agent/tools/lsp_restart.go | 9 +- internal/agent/tools/multiedit.go | 7 +- internal/agent/tools/references.go | 13 +- internal/agent/tools/view.go | 7 +- internal/agent/tools/write.go | 7 +- internal/app/app.go | 30 ++- internal/app/lsp.go | 163 ---------------- internal/db/db.go | 10 + internal/db/querier.go | 1 + internal/db/read_files.sql.go | 33 ++++ internal/db/sql/read_files.sql | 5 + internal/filetracker/service.go | 23 +++ internal/lsp/client.go | 77 +------- internal/lsp/filtermatching_test.go | 111 ----------- internal/lsp/manager.go | 271 +++++++++++++++++++++++++++ internal/ui/model/header.go | 8 +- internal/ui/model/lsp.go | 7 +- internal/ui/model/session.go | 54 +++++- internal/ui/model/ui.go | 19 +- 25 files changed, 500 insertions(+), 426 deletions(-) delete mode 100644 internal/app/lsp.go delete mode 100644 internal/lsp/filtermatching_test.go create mode 100644 internal/lsp/manager.go diff --git a/go.mod b/go.mod index bda063569bac9702747085c90f9e57ec93593a06..3a73c9cd258cadce8237f6603e52380667f3a444 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,7 @@ require ( github.com/rivo/uniseg v0.4.7 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sahilm/fuzzy v0.1.1 + github.com/sourcegraph/jsonrpc2 v0.2.1 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 @@ -150,7 +151,6 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect - github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect github.com/tidwall/match v1.1.1 // indirect diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 9bf592413b07c651171d10785104294da8fb39a3..9bb27327516da66323fee454b9048cf5f9f69b6b 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -169,7 +169,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, c.filetracker, tmpDir), + tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir), } agent := NewSessionAgent(SessionAgentOptions{ diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index 4f96c3cfbb1728f533c71a7c05b7e1ab85975b45..1a420e2b40b84027db7469a71ca9212b69f6e380 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -204,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.filetracker, env.workingDir), - tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir), + tools.NewEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir), + tools.NewMultiEditTool(nil, 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.filetracker, env.workingDir), - tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir), + tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir), + tools.NewWriteTool(nil, 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 d4e23af0c676307756f4e39fda7e10dfb2b6da5e..a6048a7620bef5236ef8266612538685dcf48aac 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -21,7 +21,6 @@ import ( "github.com/charmbracelet/crush/internal/agent/prompt" "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" @@ -67,7 +66,7 @@ type coordinator struct { permissions permission.Service history history.Service filetracker filetracker.Service - lspClients *csync.Map[string, *lsp.Client] + lspManager *lsp.Manager currentAgent SessionAgent agents map[string]SessionAgent @@ -83,7 +82,7 @@ func NewCoordinator( permissions permission.Service, history history.Service, filetracker filetracker.Service, - lspClients *csync.Map[string, *lsp.Client], + lspManager *lsp.Manager, ) (Coordinator, error) { c := &coordinator{ cfg: cfg, @@ -92,7 +91,7 @@ func NewCoordinator( permissions: permissions, history: history, filetracker: filetracker, - lspClients: lspClients, + lspManager: lspManager, agents: make(map[string]SessionAgent), } @@ -423,20 +422,21 @@ 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.filetracker, c.cfg.WorkingDir()), - tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), + tools.NewEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), + tools.NewMultiEditTool(c.lspManager, 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.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...), - tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), + tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...), + tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), ) - if c.lspClients.Len() > 0 { - allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients)) + // Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true). + if len(c.cfg.LSP) > 0 || c.cfg.Options.AutoLSP == nil || *c.cfg.Options.AutoLSP { + allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager)) } if len(c.cfg.MCP) > 0 { diff --git a/internal/agent/tools/diagnostics.go b/internal/agent/tools/diagnostics.go index 9af0da43c396d9fa8aa9776f4f7fb177af6b5806..04cf79ee793a742c00f7c8d4a1e0e869663569e4 100644 --- a/internal/agent/tools/diagnostics.go +++ b/internal/agent/tools/diagnostics.go @@ -10,7 +10,6 @@ import ( "time" "charm.land/fantasy" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" ) @@ -24,25 +23,36 @@ const DiagnosticsToolName = "lsp_diagnostics" //go:embed diagnostics.md var diagnosticsDescription []byte -func NewDiagnosticsTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool { +func NewDiagnosticsTool(lspManager *lsp.Manager) fantasy.AgentTool { return fantasy.NewAgentTool( DiagnosticsToolName, string(diagnosticsDescription), func(ctx context.Context, params DiagnosticsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { - if lspClients.Len() == 0 { + if lspManager.Clients().Len() == 0 { return fantasy.NewTextErrorResponse("no LSP clients available"), nil } - notifyLSPs(ctx, lspClients, params.FilePath) - output := getDiagnostics(params.FilePath, lspClients) + notifyLSPs(ctx, lspManager, params.FilePath) + output := getDiagnostics(params.FilePath, lspManager) return fantasy.NewTextResponse(output), nil }) } -func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filepath string) { +func notifyLSPs( + ctx context.Context, + manager *lsp.Manager, + filepath string, +) { if filepath == "" { return } - for client := range lsps.Seq() { + + if manager == nil { + return + } + + manager.Start(ctx, filepath) + + for client := range manager.Clients().Seq() { if !client.HandlesFile(filepath) { continue } @@ -52,11 +62,15 @@ func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filep } } -func getDiagnostics(filePath string, lsps *csync.Map[string, *lsp.Client]) string { +func getDiagnostics(filePath string, manager *lsp.Manager) string { + if manager == nil { + return "" + } + fileDiagnostics := []string{} projectDiagnostics := []string{} - for lspName, client := range lsps.Seq2() { + for lspName, client := range manager.Clients().Seq2() { for location, diags := range client.GetDiagnostics() { path, err := location.Path() if err != nil { diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go index 74b84c784796a97db2f379cf61fb3eb8b18934d4..8d17902f097f6e0b4ebee7d0d684618c91bb0e04 100644 --- a/internal/agent/tools/edit.go +++ b/internal/agent/tools/edit.go @@ -11,7 +11,6 @@ import ( "time" "charm.land/fantasy" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/filepathext" "github.com/charmbracelet/crush/internal/filetracker" @@ -61,7 +60,7 @@ type editContext struct { } func NewEditTool( - lspClients *csync.Map[string, *lsp.Client], + lspManager *lsp.Manager, permissions permission.Service, files history.Service, filetracker filetracker.Service, @@ -99,10 +98,10 @@ func NewEditTool( return response, nil } - notifyLSPs(ctx, lspClients, params.FilePath) + notifyLSPs(ctx, lspManager, params.FilePath) text := fmt.Sprintf("\n%s\n\n", response.Content) - text += getDiagnostics(params.FilePath, lspClients) + text += getDiagnostics(params.FilePath, lspManager) response.Content = text return response, nil }) diff --git a/internal/agent/tools/lsp_restart.go b/internal/agent/tools/lsp_restart.go index 5e5a8a90a11927079086fe407384f32ceecf10c5..588f27bfe097326b99d6090067ad9b78243a3986 100644 --- a/internal/agent/tools/lsp_restart.go +++ b/internal/agent/tools/lsp_restart.go @@ -10,7 +10,6 @@ import ( "sync" "charm.land/fantasy" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/lsp" ) @@ -25,20 +24,20 @@ type LSPRestartParams struct { Name string `json:"name,omitempty"` } -func NewLSPRestartTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool { +func NewLSPRestartTool(lspManager *lsp.Manager) fantasy.AgentTool { return fantasy.NewAgentTool( LSPRestartToolName, string(lspRestartDescription), func(ctx context.Context, params LSPRestartParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { - if lspClients.Len() == 0 { + if lspManager.Clients().Len() == 0 { return fantasy.NewTextErrorResponse("no LSP clients available to restart"), nil } clientsToRestart := make(map[string]*lsp.Client) if params.Name == "" { - maps.Insert(clientsToRestart, lspClients.Seq2()) + maps.Insert(clientsToRestart, lspManager.Clients().Seq2()) } else { - client, exists := lspClients.Get(params.Name) + client, exists := lspManager.Clients().Get(params.Name) if !exists { return fantasy.NewTextErrorResponse(fmt.Sprintf("LSP client '%s' not found", params.Name)), nil } diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go index 48736ebf311230a28b51702e0ddd3ff8df19b284..28af9206a6485900dc05356c68bcdc091c01fe02 100644 --- a/internal/agent/tools/multiedit.go +++ b/internal/agent/tools/multiedit.go @@ -11,7 +11,6 @@ import ( "time" "charm.land/fantasy" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/filepathext" "github.com/charmbracelet/crush/internal/filetracker" @@ -59,7 +58,7 @@ const MultiEditToolName = "multiedit" var multieditDescription []byte func NewMultiEditTool( - lspClients *csync.Map[string, *lsp.Client], + lspManager *lsp.Manager, permissions permission.Service, files history.Service, filetracker filetracker.Service, @@ -104,11 +103,11 @@ func NewMultiEditTool( } // Notify LSP clients about the change - notifyLSPs(ctx, lspClients, params.FilePath) + notifyLSPs(ctx, lspManager, params.FilePath) // Wait for LSP diagnostics and add them to the response text := fmt.Sprintf("\n%s\n\n", response.Content) - text += getDiagnostics(params.FilePath, lspClients) + text += getDiagnostics(params.FilePath, lspManager) response.Content = text return response, nil }) diff --git a/internal/agent/tools/references.go b/internal/agent/tools/references.go index 7f2a0d8cfebea708bbd9e00cc34076e57fb07520..c544886b9de3e60ef6932cbc2932fc0a0ab639f0 100644 --- a/internal/agent/tools/references.go +++ b/internal/agent/tools/references.go @@ -15,7 +15,6 @@ import ( "strings" "charm.land/fantasy" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" ) @@ -26,7 +25,7 @@ type ReferencesParams struct { } type referencesTool struct { - lspClients *csync.Map[string, *lsp.Client] + lspManager *lsp.Manager } const ReferencesToolName = "lsp_references" @@ -34,7 +33,7 @@ const ReferencesToolName = "lsp_references" //go:embed references.md var referencesDescription []byte -func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool { +func NewReferencesTool(lspManager *lsp.Manager) fantasy.AgentTool { return fantasy.NewAgentTool( ReferencesToolName, string(referencesDescription), @@ -43,7 +42,7 @@ func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.Agent return fantasy.NewTextErrorResponse("symbol is required"), nil } - if lspClients.Len() == 0 { + if lspManager.Clients().Len() == 0 { return fantasy.NewTextErrorResponse("no LSP clients available"), nil } @@ -61,7 +60,7 @@ func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.Agent var allLocations []protocol.Location var allErrs error for _, match := range matches { - locations, err := find(ctx, lspClients, params.Symbol, match) + locations, err := find(ctx, lspManager, params.Symbol, match) if err != nil { if strings.Contains(err.Error(), "no identifier found") { // grep probably matched a comment, string value, or something else that's irrelevant @@ -91,14 +90,14 @@ func (r *referencesTool) Name() string { return ReferencesToolName } -func find(ctx context.Context, lspClients *csync.Map[string, *lsp.Client], symbol string, match grepMatch) ([]protocol.Location, error) { +func find(ctx context.Context, lspManager *lsp.Manager, symbol string, match grepMatch) ([]protocol.Location, error) { absPath, err := filepath.Abs(match.path) if err != nil { return nil, fmt.Errorf("failed to get absolute path: %s", err) } var client *lsp.Client - for c := range lspClients.Seq() { + for c := range lspManager.Clients().Seq() { if c.HandlesFile(absPath) { client = c break diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index b26267fcef3b296babc3c9dbcee64336ef162b75..0a754dcb4fd05cc975f84e85532eeab1525c7002 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -13,7 +13,6 @@ import ( "unicode/utf8" "charm.land/fantasy" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/filepathext" "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/lsp" @@ -48,7 +47,7 @@ const ( ) func NewViewTool( - lspClients *csync.Map[string, *lsp.Client], + lspManager *lsp.Manager, permissions permission.Service, filetracker filetracker.Service, workingDir string, @@ -184,7 +183,7 @@ func NewViewTool( return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err) } - notifyLSPs(ctx, lspClients, filePath) + notifyLSPs(ctx, lspManager, filePath) output := "\n" // Format the output with line numbers output += addLineNumbers(content, params.Offset+1) @@ -195,7 +194,7 @@ func NewViewTool( params.Offset+len(strings.Split(content, "\n"))) } output += "\n\n" - output += getDiagnostics(filePath, lspClients) + output += getDiagnostics(filePath, lspManager) filetracker.RecordRead(ctx, sessionID, filePath) return fantasy.WithResponseMetadata( fantasy.NewTextResponse(output), diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go index c2f5c7d1c83efd0731e8623c1e9cbb98b9bfdd2f..fbc2b8f11e9a84a9848af8eba5d2c2d1aa8ca258 100644 --- a/internal/agent/tools/write.go +++ b/internal/agent/tools/write.go @@ -11,7 +11,6 @@ import ( "time" "charm.land/fantasy" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/filepathext" "github.com/charmbracelet/crush/internal/filetracker" @@ -45,7 +44,7 @@ type WriteResponseMetadata struct { const WriteToolName = "write" func NewWriteTool( - lspClients *csync.Map[string, *lsp.Client], + lspManager *lsp.Manager, permissions permission.Service, files history.Service, filetracker filetracker.Service, @@ -161,11 +160,11 @@ func NewWriteTool( filetracker.RecordRead(ctx, sessionID, filePath) - notifyLSPs(ctx, lspClients, params.FilePath) + notifyLSPs(ctx, lspManager, params.FilePath) result := fmt.Sprintf("File successfully written: %s", filePath) result = fmt.Sprintf("\n%s\n", result) - result += getDiagnostics(filePath, lspClients) + result += getDiagnostics(filePath, lspManager) return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result), WriteResponseMetadata{ Diff: diff, diff --git a/internal/app/app.go b/internal/app/app.go index 35534629f64e29dc39beb95c55e5873b551218a4..7e16e294c17553a030d412ecde4ad95a90d53ecc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -21,7 +21,6 @@ import ( "github.com/charmbracelet/crush/internal/agent" "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/db" "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/format" @@ -58,7 +57,7 @@ type App struct { AgentCoordinator agent.Coordinator - LSPClients *csync.Map[string, *lsp.Client] + LSPManager *lsp.Manager config *config.Config @@ -90,7 +89,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { History: files, Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools), FileTracker: filetracker.NewService(q), - LSPClients: csync.NewMap[string, *lsp.Client](), + LSPManager: lsp.NewManager(cfg), globalCtx: ctx, @@ -103,9 +102,6 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { app.setupEvents() - // Initialize LSP clients in the background. - go app.initLSPClients(ctx) - // Check for updates in the background. go app.checkForUpdates(ctx) @@ -122,6 +118,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { if err := app.InitCoderAgent(ctx); err != nil { return nil, fmt.Errorf("failed to initialize coder agent: %w", err) } + + // Set up callback for LSP state updates. + app.LSPManager.SetCallback(func(name string, client *lsp.Client) { + client.SetDiagnosticsCallback(updateLSPDiagnostics) + updateLSPState(name, client.GetServerState(), nil, client, 0) + }) + return app, nil } @@ -482,7 +485,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error { app.Permissions, app.History, app.FileTracker, - app.LSPClients, + app.LSPManager, ) if err != nil { slog.Error("Failed to create coder agent", "err", err) @@ -545,16 +548,9 @@ func (app *App) Shutdown() { // Shutdown all LSP clients. shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second) defer cancel() - for name, client := range app.LSPClients.Seq2() { - wg.Go(func() { - if err := client.Close(shutdownCtx); err != nil && - !errors.Is(err, io.EOF) && - !errors.Is(err, context.Canceled) && - err.Error() != "signal: killed" { - slog.Warn("Failed to shutdown LSP client", "name", name, "error", err) - } - }) - } + wg.Go(func() { + app.LSPManager.StopAll(shutdownCtx) + }) // Call all cleanup functions. for _, cleanup := range app.cleanupFuncs { diff --git a/internal/app/lsp.go b/internal/app/lsp.go deleted file mode 100644 index 2bb20fad3878a771ce8b6a2a4dc3688de44ba5dd..0000000000000000000000000000000000000000 --- a/internal/app/lsp.go +++ /dev/null @@ -1,163 +0,0 @@ -package app - -import ( - "cmp" - "context" - "log/slog" - "os/exec" - "slices" - "sync" - "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 - } - - // 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) - } - } - - 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 - } - wg.Go(func() { - app.createAndStartLSPClient( - ctx, name, - toOurConfig(server, app.config.LSP[name]), - slices.Contains(userConfiguredLSPs, name), - ) - }) - } - wg.Wait() - - if app.AgentCoordinator != nil { - if err := app.AgentCoordinator.UpdateModels(ctx); err != nil { - slog.Error("Failed to refresh tools after LSP startup", "error", err) - } - } -} - -// 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, - Env: in.Environment, - FileTypes: in.FileTypes, - RootMarkers: in.RootMarkers, - InitOptions: in.InitOptions, - Options: in.Settings, - Timeout: user.Timeout, - } -} - -// 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 - } - } - - 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(), app.config.Options.DebugLSP) - 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 - } - - // Set diagnostics callback - lspClient.SetDiagnosticsCallback(updateLSPDiagnostics) - - // Increase initialization timeout as some servers take more time to start. - initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(config.Timeout, 30))*time.Second) - defer cancel() - - // Initialize LSP client. - _, err = lspClient.Initialize(initCtx, app.config.WorkingDir()) - if err != nil { - slog.Error("LSP client initialization failed", "name", name, "error", err) - updateLSPState(name, lsp.StateError, err, lspClient, 0) - lspClient.Close(ctx) - return - } - - // Wait for the server to be ready. - if err := lspClient.WaitForServerReady(initCtx); err != nil { - slog.Error("Server failed to become ready", "name", name, "error", err) - // Server never reached a ready state, but let's continue anyway, as - // some functionality might still work. - lspClient.SetServerState(lsp.StateError) - updateLSPState(name, lsp.StateError, err, lspClient, 0) - } else { - // Server reached a ready state successfully. - slog.Debug("LSP server is ready", "name", name) - lspClient.SetServerState(lsp.StateReady) - updateLSPState(name, lsp.StateReady, nil, lspClient, 0) - } - - 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/db/db.go b/internal/db/db.go index 739c2087e1c1e125875d5006c86f85de37fed3be..ec4e3807057bf4ac456ad9c066a4edb00c1771d5 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -108,6 +108,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil { return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err) } + if q.listSessionReadFilesStmt, err = db.PrepareContext(ctx, listSessionReadFiles); err != nil { + return nil, fmt.Errorf("error preparing query ListSessionReadFiles: %w", err) + } if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil { return nil, fmt.Errorf("error preparing query ListSessions: %w", err) } @@ -271,6 +274,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr) } } + if q.listSessionReadFilesStmt != nil { + if cerr := q.listSessionReadFilesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listSessionReadFilesStmt: %w", cerr) + } + } if q.listSessionsStmt != nil { if cerr := q.listSessionsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listSessionsStmt: %w", cerr) @@ -368,6 +376,7 @@ type Queries struct { listLatestSessionFilesStmt *sql.Stmt listMessagesBySessionStmt *sql.Stmt listNewFilesStmt *sql.Stmt + listSessionReadFilesStmt *sql.Stmt listSessionsStmt *sql.Stmt listUserMessagesBySessionStmt *sql.Stmt recordFileReadStmt *sql.Stmt @@ -408,6 +417,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listLatestSessionFilesStmt: q.listLatestSessionFilesStmt, listMessagesBySessionStmt: q.listMessagesBySessionStmt, listNewFilesStmt: q.listNewFilesStmt, + listSessionReadFilesStmt: q.listSessionReadFilesStmt, listSessionsStmt: q.listSessionsStmt, listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt, recordFileReadStmt: q.recordFileReadStmt, diff --git a/internal/db/querier.go b/internal/db/querier.go index c233fd59f63f8b46d3e6d62e1c162f47d6d34e3f..9a72be02c12a2760a6ab2acef8765cabb0f6bd0c 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -37,6 +37,7 @@ type Querier interface { ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) ListNewFiles(ctx context.Context) ([]File, error) + ListSessionReadFiles(ctx context.Context, sessionID string) ([]ReadFile, error) ListSessions(ctx context.Context) ([]Session, error) ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error diff --git a/internal/db/read_files.sql.go b/internal/db/read_files.sql.go index b18907c1f27a3c753b6b1a2cf1ca0563c3fd78d5..c1cda5ee633ede07b2faebe38619292c994a9f50 100644 --- a/internal/db/read_files.sql.go +++ b/internal/db/read_files.sql.go @@ -48,6 +48,39 @@ type RecordFileReadParams struct { Path string `json:"path"` } +const listSessionReadFiles = `-- name: ListSessionReadFiles :many +SELECT session_id, path, read_at FROM read_files +WHERE session_id = ? +ORDER BY read_at DESC +` + +func (q *Queries) ListSessionReadFiles(ctx context.Context, sessionID string) ([]ReadFile, error) { + rows, err := q.query(ctx, q.listSessionReadFilesStmt, listSessionReadFiles, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ReadFile{} + for rows.Next() { + var i ReadFile + if err := rows.Scan( + &i.SessionID, + &i.Path, + &i.ReadAt, + ); 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 +} + func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error { _, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead, arg.SessionID, diff --git a/internal/db/sql/read_files.sql b/internal/db/sql/read_files.sql index f607312c2ba8660aa2c7030e415ce2ca7320cd6d..1ef3ce7e684b60038aef8352b914adf4c598a033 100644 --- a/internal/db/sql/read_files.sql +++ b/internal/db/sql/read_files.sql @@ -13,3 +13,8 @@ INSERT INTO read_files ( -- name: GetFileRead :one SELECT * FROM read_files WHERE session_id = ? AND path = ? LIMIT 1; + +-- name: ListSessionReadFiles :many +SELECT * FROM read_files +WHERE session_id = ? +ORDER BY read_at DESC; diff --git a/internal/filetracker/service.go b/internal/filetracker/service.go index 8f080d124e49dfc32f43796194c09ac22beaa9f1..5a92d4de1d0c2ac585c25f7f31834b94564a0f5d 100644 --- a/internal/filetracker/service.go +++ b/internal/filetracker/service.go @@ -3,6 +3,7 @@ package filetracker import ( "context" + "fmt" "log/slog" "os" "path/filepath" @@ -19,6 +20,9 @@ type Service interface { // LastReadTime returns when a file was last read. // Returns zero time if never read. LastReadTime(ctx context.Context, sessionID, path string) time.Time + + // ListReadFiles returns the paths of all files read in a session. + ListReadFiles(ctx context.Context, sessionID string) ([]string, error) } type service struct { @@ -68,3 +72,22 @@ func relpath(path string) string { } return relpath } + +// ListReadFiles returns the paths of all files read in a session. +func (s *service) ListReadFiles(ctx context.Context, sessionID string) ([]string, error) { + readFiles, err := s.q.ListSessionReadFiles(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("listing read files: %w", err) + } + + basepath, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("getting working directory: %w", err) + } + + paths := make([]string, 0, len(readFiles)) + for _, rf := range readFiles { + paths = append(paths, filepath.Join(basepath, rf.Path)) + } + return paths, nil +} diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 6420cec050e283b3061b2f87275606b4bf9720a1..c4d80d8af918a202b97d71d5d939338aa3cf1c77 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -13,12 +13,9 @@ 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" @@ -200,6 +197,8 @@ func (c *Client) Restart() error { slog.Warn("Error closing client during restart", "name", c.name, "error", err) } + c.SetServerState(StateStopped) + c.diagCountsCache = DiagnosticCounts{} c.diagCountsVersion = 0 @@ -237,7 +236,8 @@ func (c *Client) Restart() error { type ServerState int const ( - StateStarting ServerState = iota + StateStopped ServerState = iota + StateStarting StateReady StateError StateDisabled @@ -578,72 +578,3 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char // See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration) } - -// 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 - } - - type serverPatterns struct { - server *powernapconfig.ServerConfig - patterns []string - } - normalized := make(map[string]serverPatterns, len(servers)) - for name, server := range servers { - var patterns []string - for _, p := range server.RootMarkers { - if p == ".git" { - // ignore .git for discovery - continue - } - patterns = append(patterns, 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} - } - - 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 deleted file mode 100644 index 40c796916b73169b882404eecfb4625e7baaa85b..0000000000000000000000000000000000000000 --- a/internal/lsp/filtermatching_test.go +++ /dev/null @@ -1,111 +0,0 @@ -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/manager.go b/internal/lsp/manager.go new file mode 100644 index 0000000000000000000000000000000000000000..b59819e0d64a592d2c5fd7d9e8c6c9ec8d2fed38 --- /dev/null +++ b/internal/lsp/manager.go @@ -0,0 +1,271 @@ +// Package lsp provides a manager for Language Server Protocol (LSP) clients. +package lsp + +import ( + "cmp" + "context" + "errors" + "io" + "log/slog" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/fsext" + powernapconfig "github.com/charmbracelet/x/powernap/pkg/config" + "github.com/charmbracelet/x/powernap/pkg/lsp" + "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" + "github.com/sourcegraph/jsonrpc2" +) + +// Manager handles lazy initialization of LSP clients based on file types. +type Manager struct { + clients *csync.Map[string, *Client] + cfg *config.Config + manager *powernapconfig.Manager + callback func(name string, client *Client) + mu sync.Mutex +} + +// NewManager creates a new LSP manager service. +func NewManager(cfg *config.Config) *Manager { + manager := powernapconfig.NewManager() + manager.LoadDefaults() + + // Merge user-configured LSPs into the manager. + for name, clientConfig := range cfg.LSP { + if clientConfig.Disabled { + slog.Debug("LSP disabled by user config", "name", name) + manager.RemoveServer(name) + continue + } + + // HACK: the user might have the command name in their config instead + // of the actual name. Find and use the correct name. + actualName := resolveServerName(manager, name) + manager.AddServer(actualName, &powernapconfig.ServerConfig{ + Command: clientConfig.Command, + Args: clientConfig.Args, + Environment: clientConfig.Env, + FileTypes: clientConfig.FileTypes, + RootMarkers: clientConfig.RootMarkers, + InitOptions: clientConfig.InitOptions, + Settings: clientConfig.Options, + }) + } + + return &Manager{ + clients: csync.NewMap[string, *Client](), + cfg: cfg, + manager: manager, + } +} + +// Clients returns the map of LSP clients. +func (m *Manager) Clients() *csync.Map[string, *Client] { + return m.clients +} + +// SetCallback sets a callback that is invoked when a new LSP +// client is successfully started. This allows the coordinator to add LSP tools. +func (s *Manager) SetCallback(cb func(name string, client *Client)) { + s.mu.Lock() + defer s.mu.Unlock() + s.callback = cb +} + +// Start starts an LSP server that can handle the given file path. +// If an appropriate LSP is already running, this is a no-op. +func (s *Manager) Start(ctx context.Context, filePath string) { + s.mu.Lock() + defer s.mu.Unlock() + + var wg sync.WaitGroup + for name, server := range s.manager.GetServers() { + if !handles(server, filePath, s.cfg.WorkingDir()) { + continue + } + wg.Go(func() { + s.startServer(ctx, name, server) + }) + } + wg.Wait() +} + +// skipAutoStartCommands contains commands that are too generic or ambiguous to +// auto-start without explicit user configuration. +var skipAutoStartCommands = map[string]bool{ + "buck2": true, + "buf": true, + "cue": true, + "dart": true, + "deno": true, + "dotnet": true, + "dprint": true, + "gleam": true, + "java": true, + "julia": true, + "koka": true, + "node": true, + "npx": true, + "perl": true, + "plz": true, + "python": true, + "python3": true, + "R": true, + "racket": true, + "rome": true, + "rubocop": true, + "ruff": true, + "scarb": true, + "solc": true, + "stylua": true, + "swipl": true, + "tflint": true, +} + +func (s *Manager) startServer(ctx context.Context, name string, server *powernapconfig.ServerConfig) { + userConfigured := s.isUserConfigured(name) + + if !userConfigured { + if _, err := exec.LookPath(server.Command); err != nil { + slog.Debug("LSP server not installed, skipping", "name", name, "command", server.Command) + return + } + if skipAutoStartCommands[server.Command] { + slog.Debug("LSP command too generic for auto-start, skipping", "name", name, "command", server.Command) + return + } + } + + cfg := s.buildConfig(name, server) + if client, ok := s.clients.Get(name); ok { + switch client.GetServerState() { + case StateReady, StateStarting: + s.callback(name, client) + // already done, return + return + } + } + client, err := New(ctx, name, cfg, s.cfg.Resolver(), s.cfg.Options.DebugLSP) + if err != nil { + slog.Error("Failed to create LSP client", "name", name, "error", err) + return + } + s.callback(name, client) + + defer func() { + s.clients.Set(name, client) + s.callback(name, client) + }() + + initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(cfg.Timeout, 30))*time.Second) + defer cancel() + + if _, err := client.Initialize(initCtx, s.cfg.WorkingDir()); err != nil { + slog.Error("LSP client initialization failed", "name", name, "error", err) + client.Close(ctx) + return + } + + if err := client.WaitForServerReady(initCtx); err != nil { + slog.Warn("LSP server not fully ready, continuing anyway", "name", name, "error", err) + client.SetServerState(StateError) + } else { + client.SetServerState(StateReady) + } + + slog.Debug("LSP client started", "name", name) +} + +func (s *Manager) isUserConfigured(name string) bool { + cfg, ok := s.cfg.LSP[name] + return ok && !cfg.Disabled +} + +func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig { + cfg := config.LSPConfig{ + Command: server.Command, + Args: server.Args, + Env: server.Environment, + FileTypes: server.FileTypes, + RootMarkers: server.RootMarkers, + InitOptions: server.InitOptions, + Options: server.Settings, + } + if userCfg, ok := s.cfg.LSP[name]; ok { + cfg.Timeout = userCfg.Timeout + } + return cfg +} + +func resolveServerName(manager *powernapconfig.Manager, name string) string { + if _, ok := manager.GetServer(name); ok { + return name + } + for sname, server := range manager.GetServers() { + if server.Command == name { + return sname + } + } + return name +} + +func handlesFiletype(server *powernapconfig.ServerConfig, ext string, language protocol.LanguageKind) bool { + for _, ft := range server.FileTypes { + if protocol.LanguageKind(ft) == language || + ft == strings.TrimPrefix(ext, ".") || + "."+ft == ext { + return true + } + } + return false +} + +func hasRootMarkers(dir string, markers []string) bool { + if len(markers) == 0 { + return true + } + for _, pattern := range markers { + // Use fsext.GlobWithDoubleStar to find matches + matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1) + if err == nil && len(matches) > 0 { + return true + } + } + return false +} + +func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool { + language := lsp.DetectLanguage(filePath) + ext := filepath.Ext(filePath) + return handlesFiletype(server, ext, language) && + hasRootMarkers(workDir, server.RootMarkers) +} + +// StopAll stops all running LSP clients and clears the client map. +func (s *Manager) StopAll(ctx context.Context) { + s.mu.Lock() + defer s.mu.Unlock() + + var wg sync.WaitGroup + for name, client := range s.clients.Seq2() { + wg.Go(func() { + defer func() { s.callback(name, client) }() + if err := client.Close(ctx); err != nil && + !errors.Is(err, io.EOF) && + !errors.Is(err, context.Canceled) && + !errors.Is(err, jsonrpc2.ErrClosed) && + err.Error() != "signal: killed" { + slog.Warn("Failed to stop LSP client", "name", name, "error", err) + } + client.SetServerState(StateStopped) + slog.Debug("Stopped LSP client", "name", name) + }) + } + wg.Wait() +} diff --git a/internal/ui/model/header.go b/internal/ui/model/header.go index 2f6e093027783dca62f3d6cde12d61126c6061bb..92321d5d9cf67c96731fab102436f662f86cdc1b 100644 --- a/internal/ui/model/header.go +++ b/internal/ui/model/header.go @@ -74,7 +74,13 @@ func (h *header) drawHeader( b.WriteString(h.compactLogo) availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags - details := renderHeaderDetails(h.com, session, h.com.App.LSPClients, detailsOpen, availDetailWidth) + details := renderHeaderDetails( + h.com, + session, + h.com.App.LSPManager.Clients(), + detailsOpen, + availDetailWidth, + ) remainingWidth := width - lipgloss.Width(b.String()) - diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go index ef78ebfb2c4e069901e0b4433587e948f98643d1..f597c1100682a535cada2ae2c694471d9743c40a 100644 --- a/internal/ui/model/lsp.go +++ b/internal/ui/model/lsp.go @@ -31,7 +31,7 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string { var lsps []LSPInfo for _, state := range states { - client, ok := m.com.App.LSPClients.Get(state.Name) + client, ok := m.com.App.LSPManager.Clients().Get(state.Name) if !ok { continue } @@ -89,6 +89,9 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string { var description string var diagnostics string switch l.State { + case lsp.StateStopped: + icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() + description = t.Subtle.Render("stopped") case lsp.StateStarting: icon = t.ItemBusyIcon.String() description = t.Subtle.Render("starting...") @@ -103,7 +106,7 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string { } case lsp.StateDisabled: icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() - description = t.Subtle.Render("inactive") + description = t.Subtle.Render("disabled") default: icon = t.ItemOfflineIcon.String() } diff --git a/internal/ui/model/session.go b/internal/ui/model/session.go index 1438d0a914556574d513d3606bb1481cde008709..c043255c041c20523a2e14b85285bccc7ee7eeb1 100644 --- a/internal/ui/model/session.go +++ b/internal/ui/model/session.go @@ -3,6 +3,7 @@ package model import ( "context" "fmt" + "log/slog" "path/filepath" "slices" "strings" @@ -22,8 +23,32 @@ import ( // loadSessionMsg is a message indicating that a session and its files have // been loaded. type loadSessionMsg struct { - session *session.Session - files []SessionFile + session *session.Session + files []SessionFile + readFiles []string +} + +// lspFilePaths returns deduplicated file paths from both modified and read +// files for starting LSP servers. +func (msg loadSessionMsg) lspFilePaths() []string { + seen := make(map[string]struct{}, len(msg.files)+len(msg.readFiles)) + paths := make([]string, 0, len(msg.files)+len(msg.readFiles)) + for _, f := range msg.files { + p := f.LatestVersion.Path + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + paths = append(paths, p) + } + for _, p := range msg.readFiles { + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + paths = append(paths, p) + } + return paths } // SessionFile tracks the first and latest versions of a file in a session, @@ -51,9 +76,15 @@ func (m *UI) loadSession(sessionID string) tea.Cmd { return util.ReportError(err) } + readFiles, err := m.com.App.FileTracker.ListReadFiles(context.Background(), sessionID) + if err != nil { + slog.Error("Failed to load read files for session", "error", err) + } + return loadSessionMsg{ - session: &session, - files: sessionFiles, + session: &session, + files: sessionFiles, + readFiles: readFiles, } } } @@ -200,3 +231,18 @@ func fileList(t *styles.Styles, cwd string, filesWithChanges []SessionFile, widt return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...) } + +// startLSPs starts LSP servers for the given file paths. +func (m *UI) startLSPs(paths []string) tea.Cmd { + if len(paths) == 0 { + return nil + } + + return func() tea.Msg { + ctx := context.Background() + for _, path := range paths { + m.com.App.LSPManager.Start(ctx, path) + } + return nil + } +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 05fa503eb40b60560fb6cf185a16ec5342144349..71f5d977ba8715a553d6c5f162f458dcf9c1855c 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -397,6 +397,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.setState(uiChat, m.focus) m.session = msg.session m.sessionFiles = msg.files + cmds = append(cmds, m.startLSPs(msg.lspFilePaths())) msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID) if err != nil { cmds = append(cmds, util.ReportError(err)) @@ -417,8 +418,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.historyReset() cmds = append(cmds, m.loadPromptHistory()) m.updateLayoutAndSize() + case sessionFilesUpdatesMsg: m.sessionFiles = msg.sessionFiles + var paths []string + for _, f := range msg.sessionFiles { + paths = append(paths, f.LatestVersion.Path) + } + cmds = append(cmds, m.startLSPs(paths)) case sendMessageMsg: cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...)) @@ -2706,8 +2713,10 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea. m.setState(uiChat, m.focus) } + ctx := context.Background() for _, path := range m.sessionFileReads { - m.com.App.FileTracker.RecordRead(context.Background(), m.session.ID, path) + m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path) + m.com.App.LSPManager.Start(ctx, path) } // Capture session ID to avoid race with main goroutine updating m.session. @@ -2965,7 +2974,13 @@ func (m *UI) newSession() tea.Cmd { m.promptQueue = 0 m.pillsView = "" m.historyReset() - return m.loadPromptHistory() + return tea.Batch( + func() tea.Msg { + m.com.App.LSPManager.StopAll(context.Background()) + return nil + }, + m.loadPromptHistory(), + ) } // handlePasteMsg handles a paste message. From f285804b6c5c45d0a37fad2e2edb0e56fe8aa034 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 6 Feb 2026 16:31:53 -0300 Subject: [PATCH 061/125] refactor: remove empty slice declarations (#2150) --- internal/agent/tools/diagnostics.go | 6 +++--- internal/cmd/logs.go | 4 ++-- internal/config/config.go | 2 +- internal/config/load.go | 8 +------- internal/config/load_test.go | 2 +- internal/ui/chat/agent.go | 2 +- internal/ui/chat/lsp_restart.go | 2 +- internal/ui/common/elements.go | 2 +- internal/ui/dialog/arguments.go | 2 +- internal/ui/model/lsp.go | 2 +- internal/ui/model/mcp.go | 2 +- 11 files changed, 14 insertions(+), 20 deletions(-) diff --git a/internal/agent/tools/diagnostics.go b/internal/agent/tools/diagnostics.go index 04cf79ee793a742c00f7c8d4a1e0e869663569e4..41a1b8abfa8e54c32de783cd2bf1da11f3bdf264 100644 --- a/internal/agent/tools/diagnostics.go +++ b/internal/agent/tools/diagnostics.go @@ -67,8 +67,8 @@ func getDiagnostics(filePath string, manager *lsp.Manager) string { return "" } - fileDiagnostics := []string{} - projectDiagnostics := []string{} + var fileDiagnostics []string + var projectDiagnostics []string for lspName, client := range manager.Clients().Seq2() { for location, diags := range client.GetDiagnostics() { @@ -163,7 +163,7 @@ func formatDiagnostic(pth string, diagnostic protocol.Diagnostic, source string) tagsInfo := "" if len(diagnostic.Tags) > 0 { - tags := []string{} + var tags []string for _, tag := range diagnostic.Tags { switch tag { case protocol.Unnecessary: diff --git a/internal/cmd/logs.go b/internal/cmd/logs.go index 4c66d499a08393972ec1ad740ddb4c29293b88d9..804b23310fa1e3fb86e4b32983bfcdd571df47aa 100644 --- a/internal/cmd/logs.go +++ b/internal/cmd/logs.go @@ -171,8 +171,8 @@ func printLogLine(lineText string) { } msg := data["msg"] level := data["level"] - otherData := []any{} - keys := []string{} + var otherData []any + var keys []string for k := range data { keys = append(keys, k) } diff --git a/internal/config/config.go b/internal/config/config.go index 9a5f0eebfcd557f0ef71e5da471bc3348aeb5d55..2de7afbc106c48214716e1338236768e77ed2e97 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -730,7 +730,7 @@ func resolveReadOnlyTools(tools []string) []string { } func filterSlice(data []string, mask []string, include bool) []string { - filtered := []string{} + var filtered []string for _, s := range data { // if include is true, we include items that ARE in the mask // if include is false, we include items that are NOT in the mask diff --git a/internal/config/load.go b/internal/config/load.go index a651f4846307ed9729ba8a10835e98aece486dbd..0815f86d0faa4b94476c6c57670371b3eb5632f6 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -95,7 +95,7 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) { } func PushPopCrushEnv() func() { - found := []string{} + var found []string for _, ev := range os.Environ() { if strings.HasPrefix(ev, "CRUSH_") { pair := strings.SplitN(ev, "=", 2) @@ -342,12 +342,6 @@ func (c *Config) setDefaults(workingDir, dataDir string) { if c.Options.TUI == nil { c.Options.TUI = &TUIOptions{} } - if c.Options.ContextPaths == nil { - c.Options.ContextPaths = []string{} - } - if c.Options.SkillsPaths == nil { - c.Options.SkillsPaths = []string{} - } if dataDir != "" { c.Options.DataDirectory = dataDir } else if c.Options.DataDirectory == "" { diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 0e23d22485c23e2b5e1c5fb1a98a86b561e00540..7229ac51d4a4c0616f268ea2a592cb12e1b818bb 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -513,7 +513,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) { taskAgent, ok := cfg.Agents[AgentTask] require.True(t, ok) - assert.Equal(t, []string{}, taskAgent.AllowedTools) + assert.Len(t, taskAgent.AllowedTools, 0) } func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) { diff --git a/internal/ui/chat/agent.go b/internal/ui/chat/agent.go index c2a439ff23d0bd046b75076ea30de68b60cdcc54..e8840cc53be358010dc006aef6961290902b5983 100644 --- a/internal/ui/chat/agent.go +++ b/internal/ui/chat/agent.go @@ -242,7 +242,7 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int prompt = strings.ReplaceAll(prompt, "\n", " ") // Build header with optional URL param. - toolParams := []string{} + var toolParams []string if params.URL != "" { toolParams = append(toolParams, params.URL) } diff --git a/internal/ui/chat/lsp_restart.go b/internal/ui/chat/lsp_restart.go index 66c316fcaf7c949711babeb9ebe864e558ae5bc0..a75dce932ed5fc2da1757e4a139a93d0d7d3fab4 100644 --- a/internal/ui/chat/lsp_restart.go +++ b/internal/ui/chat/lsp_restart.go @@ -38,7 +38,7 @@ func (r *LSPRestartToolRenderContext) RenderTool(sty *styles.Styles, width int, var params tools.LSPRestartParams _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) - toolParams := []string{} + var toolParams []string if params.Name != "" { toolParams = append(toolParams, params.Name) } diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index a129d1861e483c5c2064dc70d70ebd5c09cbd1f8..1477b2a5208e31831a1725042daa6190364ced46 100644 --- a/internal/ui/common/elements.go +++ b/internal/ui/common/elements.go @@ -137,7 +137,7 @@ func Status(t *styles.Styles, opts StatusOpts, width int) string { description = t.Base.Foreground(descriptionColor).Render(description) } - content := []string{} + var content []string if icon != "" { content = append(content, icon) } diff --git a/internal/ui/dialog/arguments.go b/internal/ui/dialog/arguments.go index 96eff11940841e2377e85fafeab9850fb844f139..5cec78593a15356b8fd18d952f78e88c7f158bab 100644 --- a/internal/ui/dialog/arguments.go +++ b/internal/ui/dialog/arguments.go @@ -342,7 +342,7 @@ func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { if scrollbar != "" { content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar) } - contentParts := []string{} + var contentParts []string if description != "" { contentParts = append(contentParts, description) } diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go index f597c1100682a535cada2ae2c694471d9743c40a..9566e7b28403685e4a961e01158cfbf027d5e156 100644 --- a/internal/ui/model/lsp.go +++ b/internal/ui/model/lsp.go @@ -60,7 +60,7 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string { // lspDiagnostics formats diagnostic counts with appropriate icons and colors. func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string { - errs := []string{} + var errs []string if diagnostics[protocol.SeverityError] > 0 { errs = append(errs, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, diagnostics[protocol.SeverityError]))) } diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go index 3345841618f0fdb6663fec80eb7784b1297c329c..517016f0dcb9b5f237d4ac09c9816a290a42fdcc 100644 --- a/internal/ui/model/mcp.go +++ b/internal/ui/model/mcp.go @@ -36,7 +36,7 @@ func (m *UI) mcpInfo(width, maxItems int, isSection bool) string { // mcpCounts formats tool, prompt, and resource counts for display. func mcpCounts(t *styles.Styles, counts mcp.Counts) string { - parts := []string{} + var parts []string if counts.Tools > 0 { parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d tools", counts.Tools))) } From afb5be912ec2f98493fa7bbef41de3f4b53d9749 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 6 Feb 2026 14:22:27 -0300 Subject: [PATCH 062/125] chore: update `charm.land/catwalk` with minimax support https://github.com/charmbracelet/catwalk/pull/98 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3a73c9cd258cadce8237f6603e52380667f3a444..383d79cca8339dfae199a2487f39f0377410197a 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +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.1 + charm.land/catwalk v0.17.1 charm.land/fantasy v0.7.1 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 diff --git a/go.sum b/go.sum index 5d5d296dbc94806e3d06a24190949548668671bd..34cb7024b27660846968efc1013cf85de9a9f029 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +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.1 h1:4Z4uCxqdAaVHeSX5dDDOkOg8sm7krFqJSaNBMZhE7Ao= -charm.land/catwalk v0.16.1/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64= +charm.land/catwalk v0.17.1 h1:UsHvBi3S7CxONiIZTWKTXM+H9qla8I0fCb/SVru33ms= +charm.land/catwalk v0.17.1/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64= charm.land/fantasy v0.7.1 h1:JOCYeLz32PM11y1u08/YgWl3LfPwhjOIuoyjBXjFofI= charm.land/fantasy v0.7.1/go.mod h1:zv8Utaob4b9rSPp2ruH515rx7oN+l66gv6RshvwHnww= charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0= From 59887a301733b1ba9cd1949cec1ac05d321e53a9 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 6 Feb 2026 13:26:33 -0300 Subject: [PATCH 063/125] refactor: cleanup the code a bit --- internal/config/config.go | 48 +++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 2de7afbc106c48214716e1338236768e77ed2e97..fcca3d0b3aded2b35d1ed069d3ed379faf4b59c3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -773,42 +773,48 @@ func (c *Config) Resolver() VariableResolver { } func (c *ProviderConfig) TestConnection(resolver VariableResolver) error { - testURL := "" - headers := make(map[string]string) - apiKey, _ := resolver.ResolveValue(c.APIKey) + var ( + providerID = catwalk.InferenceProvider(c.ID) + testURL = "" + headers = make(map[string]string) + apiKey, _ = resolver.ResolveValue(c.APIKey) + ) + switch c.Type { case catwalk.TypeOpenAI, catwalk.TypeOpenAICompat, catwalk.TypeOpenRouter: baseURL, _ := resolver.ResolveValue(c.BaseURL) - if baseURL == "" { - baseURL = "https://api.openai.com/v1" - } - if c.ID == string(catwalk.InferenceProviderOpenRouter) { + baseURL = cmp.Or(baseURL, "https://api.openai.com/v1") + + switch providerID { + case catwalk.InferenceProviderOpenRouter: testURL = baseURL + "/credits" - } else { + default: testURL = baseURL + "/models" } + headers["Authorization"] = "Bearer " + apiKey case catwalk.TypeAnthropic: baseURL, _ := resolver.ResolveValue(c.BaseURL) - if baseURL == "" { - baseURL = "https://api.anthropic.com/v1" - } - testURL = baseURL + "/models" - // TODO: replace with const when catwalk is released - if c.ID == "kimi-coding" { + baseURL = cmp.Or(baseURL, "https://api.anthropic.com/v1") + + switch providerID { + case catwalk.InferenceKimiCoding: testURL = baseURL + "/v1/models" + default: + testURL = baseURL + "/models" } + headers["x-api-key"] = apiKey headers["anthropic-version"] = "2023-06-01" case catwalk.TypeGoogle: baseURL, _ := resolver.ResolveValue(c.BaseURL) - if baseURL == "" { - baseURL = "https://generativelanguage.googleapis.com" - } + baseURL = cmp.Or(baseURL, "https://generativelanguage.googleapis.com") testURL = baseURL + "/v1beta/models?key=" + url.QueryEscape(apiKey) } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + client := &http.Client{} req, err := http.NewRequestWithContext(ctx, "GET", testURL, nil) if err != nil { @@ -820,17 +826,19 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error { for k, v := range c.ExtraHeaders { req.Header.Set(k, v) } + resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to create request for provider %s: %w", c.ID, err) } defer resp.Body.Close() - if c.ID == string(catwalk.InferenceProviderZAI) { + + switch providerID { + case catwalk.InferenceProviderZAI: if resp.StatusCode == http.StatusUnauthorized { - // For z.ai just check if the http response is not 401. return fmt.Errorf("failed to connect to provider %s: %s", c.ID, resp.Status) } - } else { + default: if resp.StatusCode != http.StatusOK { return fmt.Errorf("failed to connect to provider %s: %s", c.ID, resp.Status) } From 54a6978534ced00f11e5ced64205ddee32af8320 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 6 Feb 2026 14:23:24 -0300 Subject: [PATCH 064/125] fix: make it possible to add api key for minimax It has not good endpoint to validate the API key, so for now we're skipping it. --- internal/config/config.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index fcca3d0b3aded2b35d1ed069d3ed379faf4b59c3..07608f4ff59abda3347b032673b9bb2c3705c2e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -780,6 +780,16 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error { apiKey, _ = resolver.ResolveValue(c.APIKey) ) + switch providerID { + case catwalk.InferenceProviderMiniMax: + // NOTE: MiniMax has no good endpoint we can use to validate the API key. + // Let's at least check the pattern. + if !strings.HasPrefix(apiKey, "sk-") { + return fmt.Errorf("invalid API key format for provider %s", c.ID) + } + return nil + } + switch c.Type { case catwalk.TypeOpenAI, catwalk.TypeOpenAICompat, catwalk.TypeOpenRouter: baseURL, _ := resolver.ResolveValue(c.BaseURL) From e54c5ae7a1c5c9dbce4582ec8fca2dbe4d437269 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 6 Feb 2026 14:24:28 -0300 Subject: [PATCH 065/125] docs(readme): add mention to minimax --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6e167345dd92ffb7a4d56241e9da7258a7c89b97..76d9daefc3fecbdf889a40ddfa0d3f2ea0e4cb0f 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ That said, you can also set environment variables for preferred providers. | `GEMINI_API_KEY` | Google Gemini | | `SYNTHETIC_API_KEY` | Synthetic | | `ZAI_API_KEY` | Z.ai | +| `MINIMAX_API_KEY` | MiniMax | | `HF_TOKEN` | Hugging Face Inference | | `CEREBRAS_API_KEY` | Cerebras | | `OPENROUTER_API_KEY` | OpenRouter | From dc890f399504b6158a8d6b70edc449b7cb646a8a Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 6 Feb 2026 17:00:42 -0300 Subject: [PATCH 066/125] fix: build linux/386 (#2153) Signed-off-by: Carlos Alexandro Becker --- internal/ui/model/clipboard.go | 15 +++++++++++++++ internal/ui/model/clipboard_linux_386.go | 7 +++++++ internal/ui/model/clipboard_other.go | 15 +++++++++++++++ internal/ui/model/ui.go | 5 ++--- 4 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 internal/ui/model/clipboard.go create mode 100644 internal/ui/model/clipboard_linux_386.go create mode 100644 internal/ui/model/clipboard_other.go diff --git a/internal/ui/model/clipboard.go b/internal/ui/model/clipboard.go new file mode 100644 index 0000000000000000000000000000000000000000..dfe42be5d76ee55330e5aa67e883cd75af892511 --- /dev/null +++ b/internal/ui/model/clipboard.go @@ -0,0 +1,15 @@ +package model + +import "errors" + +type clipboardFormat int + +const ( + clipboardFormatText clipboardFormat = iota + clipboardFormatImage +) + +var ( + errClipboardPlatformUnsupported = errors.New("clipboard operations are not supported on this platform") + errClipboardUnknownFormat = errors.New("unknown clipboard format") +) diff --git a/internal/ui/model/clipboard_linux_386.go b/internal/ui/model/clipboard_linux_386.go new file mode 100644 index 0000000000000000000000000000000000000000..a162d9cc2a3e0e412942a845308a5022d234608d --- /dev/null +++ b/internal/ui/model/clipboard_linux_386.go @@ -0,0 +1,7 @@ +//go:build linux && 386 + +package model + +func readClipboard(clipboardFormat) ([]byte, error) { + return nil, errClipboardPlatformUnsupported +} diff --git a/internal/ui/model/clipboard_other.go b/internal/ui/model/clipboard_other.go new file mode 100644 index 0000000000000000000000000000000000000000..e997530b269c7ff9b89cd268f8aeba0311e922cc --- /dev/null +++ b/internal/ui/model/clipboard_other.go @@ -0,0 +1,15 @@ +//go:build !linux || !386 + +package model + +import "github.com/aymanbagabas/go-nativeclipboard" + +func readClipboard(f clipboardFormat) ([]byte, error) { + switch f { + case clipboardFormatText: + return nativeclipboard.Text.Read() + case clipboardFormatImage: + return nativeclipboard.Image.Read() + } + return nil, errClipboardUnknownFormat +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 71f5d977ba8715a553d6c5f162f458dcf9c1855c..60511aca1ab60c2d4fb2aaf084842078162fa6a3 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -24,7 +24,6 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/catwalk/pkg/catwalk" "charm.land/lipgloss/v2" - nativeclipboard "github.com/aymanbagabas/go-nativeclipboard" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/commands" @@ -3086,7 +3085,7 @@ func (m *UI) handleFilePathPaste(path string) tea.Cmd { // creates an attachment. If no image data is found, it falls back to // interpreting clipboard text as a file path. func (m *UI) pasteImageFromClipboard() tea.Msg { - imageData, err := nativeclipboard.Image.Read() + imageData, err := readClipboard(clipboardFormatImage) if int64(len(imageData)) > common.MaxAttachmentSize { return util.InfoMsg{ Type: util.InfoTypeError, @@ -3103,7 +3102,7 @@ func (m *UI) pasteImageFromClipboard() tea.Msg { } } - textData, textErr := nativeclipboard.Text.Read() + textData, textErr := readClipboard(clipboardFormatText) if textErr != nil || len(textData) == 0 { return util.NewInfoMsg("Clipboard is empty or does not contain an image") } From c0d2a3fa8741075e2cfeea3a0996e783610bbaf9 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 6 Feb 2026 17:09:28 -0300 Subject: [PATCH 067/125] ci: fix pure go build --- .../{clipboard_linux_386.go => clipboard_not_supported.go} | 2 +- .../ui/model/{clipboard_other.go => clipboard_supported.go} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename internal/ui/model/{clipboard_linux_386.go => clipboard_not_supported.go} (61%) rename internal/ui/model/{clipboard_other.go => clipboard_supported.go} (80%) diff --git a/internal/ui/model/clipboard_linux_386.go b/internal/ui/model/clipboard_not_supported.go similarity index 61% rename from internal/ui/model/clipboard_linux_386.go rename to internal/ui/model/clipboard_not_supported.go index a162d9cc2a3e0e412942a845308a5022d234608d..8550b3be0b84ac7c7fb7c03edef83a0ded5c4167 100644 --- a/internal/ui/model/clipboard_linux_386.go +++ b/internal/ui/model/clipboard_not_supported.go @@ -1,4 +1,4 @@ -//go:build linux && 386 +//go:build !(darwin || linux || windows) || arm || 386 || ios || android package model diff --git a/internal/ui/model/clipboard_other.go b/internal/ui/model/clipboard_supported.go similarity index 80% rename from internal/ui/model/clipboard_other.go rename to internal/ui/model/clipboard_supported.go index e997530b269c7ff9b89cd268f8aeba0311e922cc..ccc10da5ec5eb26ed6d51030ac0a8ecc228a4506 100644 --- a/internal/ui/model/clipboard_other.go +++ b/internal/ui/model/clipboard_supported.go @@ -1,4 +1,4 @@ -//go:build !linux || !386 +//go:build (linux || darwin || windows) && !arm && !386 && !ios && !android package model From e9b59c4b8cd7c6613cd48e533dd6b319c6bd7c69 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 6 Feb 2026 17:10:01 -0300 Subject: [PATCH 068/125] fix: thinking sidebar (#2151) Signed-off-by: Carlos Alexandro Becker --- internal/ui/model/sidebar.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index c3498b964ca5ebbc2446ffc31855a1c225a7ab5e..ea751a596077cdc809f5f0cee518e08c57f0c1fb 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -27,7 +27,7 @@ func (m *UI) modelInfo(width int) string { // Only check reasoning if model can reason if model.CatwalkCfg.CanReason { - if model.ModelCfg.ReasoningEffort == "" { + if len(model.CatwalkCfg.ReasoningLevels) == 0 { if model.ModelCfg.Think { reasoningInfo = "Thinking On" } else { From 6a8b0d9ef02247870a14fa2cc9a1b7df05ccbc39 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sun, 8 Feb 2026 05:15:21 -0300 Subject: [PATCH 069/125] chore(legal): @biisal 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 e12f0c702c6587be7d98724543a2c0ae40c1e98a..c21ec050d1f2c861a3f9005bae1d1e1d82ad87ca 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1207,6 +1207,14 @@ "created_at": "2026-02-06T07:16:50Z", "repoId": 987670088, "pullRequestNo": 2142 + }, + { + "name": "biisal", + "id": 153633053, + "comment_id": 3866503536, + "created_at": "2026-02-08T08:15:11Z", + "repoId": 987670088, + "pullRequestNo": 2164 } ] } \ No newline at end of file From 6dbdd46c159af22c80b76a81a1417ffa71b74b9d Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:27:22 -0300 Subject: [PATCH 070/125] chore(legal): @mishudark 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 c21ec050d1f2c861a3f9005bae1d1e1d82ad87ca..2e830964060397fee2517963487fe4513020bfc8 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1215,6 +1215,14 @@ "created_at": "2026-02-08T08:15:11Z", "repoId": 987670088, "pullRequestNo": 2164 + }, + { + "name": "mishudark", + "id": 211144, + "comment_id": 3866939317, + "created_at": "2026-02-08T10:27:09Z", + "repoId": 987670088, + "pullRequestNo": 2165 } ] } \ No newline at end of file From c3d4ece8b5f889a0905a63d816554727908a6081 Mon Sep 17 00:00:00 2001 From: Avisek Ray <153633053+biisal@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:06:02 +0530 Subject: [PATCH 071/125] fix(ui): `highlighter` now modifies the cell directly. (#2171) --- internal/ui/list/highlight.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/list/highlight.go b/internal/ui/list/highlight.go index 631181db29ce5bc3a2087de30341342f0374b229..5d1eb94d4c780cd1ceb67b203fa8ede784cf5cb8 100644 --- a/internal/ui/list/highlight.go +++ b/internal/ui/list/highlight.go @@ -126,7 +126,7 @@ func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, } cell := line.At(x) if cell != nil { - line.Set(x, highlighter(x, y, cell)) + highlighter(x, y, cell) } } } From fb9eb927de330fb1e23772daefdee871710a4829 Mon Sep 17 00:00:00 2001 From: M1xA Date: Mon, 9 Feb 2026 08:44:43 +0200 Subject: [PATCH 072/125] fix(ui): clear image cache when FilePicker closes to prevent unbounded memory growth (#2158) * fix(ui): clear image cache when FilePicker closes to prevent unbounded memory growth * fix(ui): only reset image cache on FilePicker close, not on new session * fix(ui): reset image cache after Dialog close * fix(ui): rename image.Reset to image.ResetCache for clarity --- internal/ui/image/image.go | 7 +++++ internal/ui/image/image_test.go | 46 +++++++++++++++++++++++++++++++++ internal/ui/model/ui.go | 9 +++++++ 3 files changed, 62 insertions(+) create mode 100644 internal/ui/image/image_test.go diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index 5af0ca7c4776cd45371d2a57e3a13dc6195b524e..d7965dcfad5e9217e8947df1ee764779c05a75f9 100644 --- a/internal/ui/image/image.go +++ b/internal/ui/image/image.go @@ -68,6 +68,13 @@ var ( cachedMutex sync.RWMutex ) +// ResetCache clears the image cache, freeing all cached decoded images. +func ResetCache() { + cachedMutex.Lock() + clear(cachedImages) + cachedMutex.Unlock() +} + // fitImage resizes the image to fit within the specified dimensions in // terminal cells, maintaining the aspect ratio. func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image { diff --git a/internal/ui/image/image_test.go b/internal/ui/image/image_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b92f4e1f695b9408de2f56ddbcae1b6084c75bac --- /dev/null +++ b/internal/ui/image/image_test.go @@ -0,0 +1,46 @@ +package image + +import ( + "image" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResetCache(t *testing.T) { + t.Parallel() + + cachedMutex.Lock() + cachedImages[imageKey{id: "a", cols: 10, rows: 10}] = cachedImage{ + img: image.NewRGBA(image.Rect(0, 0, 1, 1)), + cols: 10, + rows: 10, + } + cachedImages[imageKey{id: "b", cols: 20, rows: 20}] = cachedImage{ + img: image.NewRGBA(image.Rect(0, 0, 1, 1)), + cols: 20, + rows: 20, + } + cachedMutex.Unlock() + + ResetCache() + + cachedMutex.RLock() + length := len(cachedImages) + cachedMutex.RUnlock() + + require.Equal(t, 0, length) +} + +func TestResetIdempotent(t *testing.T) { + t.Parallel() + + // Calling Reset on an empty cache should not panic. + ResetCache() + + cachedMutex.RLock() + length := len(cachedImages) + cachedMutex.RUnlock() + + require.Equal(t, 0, length) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 60511aca1ab60c2d4fb2aaf084842078162fa6a3..498b9c3fe3e5bb47d51d76bab5d310da204ab206 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -41,6 +41,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/completions" "github.com/charmbracelet/crush/internal/ui/dialog" + fimage "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/ui/util" @@ -1137,6 +1138,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { break } + if m.dialog.ContainsDialog(dialog.FilePickerID) { + defer fimage.ResetCache() + } + m.dialog.CloseFrontDialog() if isOnboarding { @@ -1351,6 +1356,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { m.dialog.CloseDialog(dialog.FilePickerID) return nil }, + func() tea.Msg { + fimage.ResetCache() + return nil + }, )) case dialog.ActionRunCustomCommand: From 04d05e158480513828a2c2538853432a92f2a630 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 9 Feb 2026 10:50:25 +0300 Subject: [PATCH 073/125] chore: bump bubbletea and ultraviolet dependencies --- go.mod | 6 +-- go.sum | 12 ++--- internal/ui/common/capabilities.go | 3 +- internal/ui/model/landing.go | 4 +- internal/ui/model/sidebar.go | 3 +- internal/ui/model/ui.go | 79 +++++++++++++++--------------- 6 files changed, 55 insertions(+), 52 deletions(-) diff --git a/go.mod b/go.mod index 383d79cca8339dfae199a2487f39f0377410197a..315c0539463a9edd6b6afde8195baa5067702309 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,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/bubbletea/v2 v2.0.0-rc.2.0.20260209074636-30878e43d7b0 charm.land/catwalk v0.17.1 charm.land/fantasy v0.7.1 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b @@ -22,8 +22,8 @@ require ( github.com/charlievieth/fastwalk v1.0.14 github.com/charmbracelet/colorprofile v0.4.1 github.com/charmbracelet/fang v0.4.4 - github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 - github.com/charmbracelet/x/ansi v0.11.4 + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 + github.com/charmbracelet/x/ansi v0.11.6 github.com/charmbracelet/x/editor v0.2.0 github.com/charmbracelet/x/etag v0.2.0 github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f diff --git a/go.sum b/go.sum index 34cb7024b27660846968efc1013cf85de9a9f029..e6b69ffb19e4d2a1cc2003aa1c26b0fa423fe418 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 h1:2BdJynsAW+8rv9xq6ZS+x0mtacfxpxjIK1KUIeTqBOs= 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/bubbletea/v2 v2.0.0-rc.2.0.20260209074636-30878e43d7b0 h1:HAbpM9TPjZM18D677ww3VnkKXdd2hyMQtHUsVV0HcPQ= +charm.land/bubbletea/v2 v2.0.0-rc.2.0.20260209074636-30878e43d7b0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/catwalk v0.17.1 h1:UsHvBi3S7CxONiIZTWKTXM+H9qla8I0fCb/SVru33ms= charm.land/catwalk v0.17.1/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64= charm.land/fantasy v0.7.1 h1:JOCYeLz32PM11y1u08/YgWl3LfPwhjOIuoyjBXjFofI= @@ -102,10 +102,10 @@ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= -github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 h1:j3PW2hypGoPKBy3ooKzW0TFxaxhyHK3NbkLLn4KeRFc= -github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560/go.mod h1:VWATWLRwYP06VYCEur7FsNR2B1xAo7Y+xl1PTbd1ePc= -github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI= -github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIRg8gGWwk= github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY= github.com/charmbracelet/x/etag v0.2.0 h1:Euj1VkheoHfTYA9y+TCwkeXF/hN8Fb9l4LqZl79pt04= diff --git a/internal/ui/common/capabilities.go b/internal/ui/common/capabilities.go index 6636976d7d4f86d9283be2db759b44f948ad40f5..b9a9de674d4b17e4ac59ff41a93b9f0db2e0028a 100644 --- a/internal/ui/common/capabilities.go +++ b/internal/ui/common/capabilities.go @@ -47,7 +47,7 @@ func (c *Capabilities) Update(msg any) { case tea.WindowSizeMsg: c.Columns = m.Width c.Rows = m.Height - case uv.WindowPixelSizeEvent: + case uv.PixelSizeEvent: c.PixelX = m.Width c.PixelY = m.Height case uv.KittyGraphicsEvent: @@ -71,6 +71,7 @@ func (c *Capabilities) Update(msg any) { func QueryCmd(env uv.Environ) tea.Cmd { var sb strings.Builder sb.WriteString(ansi.RequestPrimaryDeviceAttributes) + sb.WriteString(ansi.QueryModifyOtherKeys) // Queries that should only be sent to "smart" normal terminals. shouldQueryFor := shouldQueryCapabilities(env) diff --git a/internal/ui/model/landing.go b/internal/ui/model/landing.go index a90ef76fdaf779e61477f5a05fd92a68d2e8a257..45d376ff5ddc691b978e438ddef04a702af100f9 100644 --- a/internal/ui/model/landing.go +++ b/internal/ui/model/landing.go @@ -4,7 +4,7 @@ import ( "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/agent" "github.com/charmbracelet/crush/internal/ui/common" - uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/ultraviolet/layout" ) // selectedLargeModel returns the currently selected large language model from @@ -31,7 +31,7 @@ func (m *UI) landingView() string { parts = append(parts, "", m.modelInfo(width)) infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...) - _, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1)) + _, remainingHeightArea := layout.SplitVertical(m.layout.main, layout.Fixed(lipgloss.Height(infoSection)+1)) mcpLspSectionWidth := min(30, (width-1)/2) diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index ea751a596077cdc809f5f0cee518e08c57f0c1fb..221405a0a276a38c531223f7ed5adde1139c6ef8 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/logo" uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/ultraviolet/layout" "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -134,7 +135,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) { blocks..., ) - _, remainingHeightArea := uv.SplitVertical(m.layout.sidebar, uv.Fixed(lipgloss.Height(sidebarHeader))) + _, remainingHeightArea := layout.SplitVertical(m.layout.sidebar, layout.Fixed(lipgloss.Height(sidebarHeader))) remainingHeight := remainingHeightArea.Dy() - 10 maxFiles, maxLSPs, maxMCPs := getDynamicHeightLimits(remainingHeight) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 498b9c3fe3e5bb47d51d76bab5d310da204ab206..77dfc9a726f466fafaacede483438629e293ae2f 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -47,6 +47,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/util" "github.com/charmbracelet/crush/internal/version" uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/ultraviolet/layout" "github.com/charmbracelet/ultraviolet/screen" "github.com/charmbracelet/x/editor" ) @@ -135,7 +136,7 @@ type UI struct { // The width and height of the terminal in cells. width int height int - layout layout + layout uiLayout isTransparent bool @@ -2235,7 +2236,7 @@ func (m *UI) updateSize() { // generateLayout calculates the layout rectangles for all UI components based // on the current UI state and terminal dimensions. -func (m *UI) generateLayout(w, h int) layout { +func (m *UI) generateLayout(w, h int) uiLayout { // The screen area we're working with area := image.Rect(0, 0, w, h) @@ -2256,7 +2257,7 @@ func (m *UI) generateLayout(w, h int) layout { } // Add app margins - appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight)) + appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight)) appRect.Min.Y += 1 appRect.Max.Y -= 1 helpRect.Min.Y -= 1 @@ -2269,7 +2270,7 @@ func (m *UI) generateLayout(w, h int) layout { appRect.Max.X -= 1 } - layout := layout{ + uiLayout := uiLayout{ area: area, status: helpRect, } @@ -2285,9 +2286,9 @@ func (m *UI) generateLayout(w, h int) layout { // ------ // help - headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight)) - layout.header = headerRect - layout.main = mainRect + headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight)) + uiLayout.header = headerRect + uiLayout.main = mainRect case uiLanding: // Layout @@ -2299,14 +2300,14 @@ func (m *UI) generateLayout(w, h int) layout { // editor // ------ // help - headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight)) - mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight)) + mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight)) // Remove extra padding from editor (but keep it for header and main) editorRect.Min.X -= 1 editorRect.Max.X += 1 - layout.header = headerRect - layout.main = mainRect - layout.editor = editorRect + uiLayout.header = headerRect + uiLayout.main = mainRect + uiLayout.editor = editorRect case uiChat: if m.isCompact { @@ -2320,28 +2321,28 @@ func (m *UI) generateLayout(w, h int) layout { // ------ // help const compactHeaderHeight = 1 - headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight)) + headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight)) detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header - sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight)) - layout.sessionDetails = sessionDetailsArea - layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header + sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight)) + uiLayout.sessionDetails = sessionDetailsArea + uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header // Add one line gap between header and main content mainRect.Min.Y += 1 - mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight)) mainRect.Max.X -= 1 // Add padding right - layout.header = headerRect + uiLayout.header = headerRect pillsHeight := m.pillsAreaHeight() if pillsHeight > 0 { pillsHeight = min(pillsHeight, mainRect.Dy()) - chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight)) - layout.main = chatRect - layout.pills = pillsRect + chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight)) + uiLayout.main = chatRect + uiLayout.pills = pillsRect } else { - layout.main = mainRect + uiLayout.main = mainRect } // Add bottom margin to main - layout.main.Max.Y -= 1 - layout.editor = editorRect + uiLayout.main.Max.Y -= 1 + uiLayout.editor = editorRect } else { // Layout // @@ -2352,40 +2353,40 @@ func (m *UI) generateLayout(w, h int) layout { // ---------- // help - mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth)) + mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth)) // Add padding left sideRect.Min.X += 1 - mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight)) mainRect.Max.X -= 1 // Add padding right - layout.sidebar = sideRect + uiLayout.sidebar = sideRect pillsHeight := m.pillsAreaHeight() if pillsHeight > 0 { pillsHeight = min(pillsHeight, mainRect.Dy()) - chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight)) - layout.main = chatRect - layout.pills = pillsRect + chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight)) + uiLayout.main = chatRect + uiLayout.pills = pillsRect } else { - layout.main = mainRect + uiLayout.main = mainRect } // Add bottom margin to main - layout.main.Max.Y -= 1 - layout.editor = editorRect + uiLayout.main.Max.Y -= 1 + uiLayout.editor = editorRect } } - if !layout.editor.Empty() { + if !uiLayout.editor.Empty() { // Add editor margins 1 top and bottom if len(m.attachments.List()) == 0 { - layout.editor.Min.Y += 1 + uiLayout.editor.Min.Y += 1 } - layout.editor.Max.Y -= 1 + uiLayout.editor.Max.Y -= 1 } - return layout + return uiLayout } -// layout defines the positioning of UI elements. -type layout struct { +// uiLayout defines the positioning of UI elements. +type uiLayout struct { // area is the overall available area. area uv.Rectangle From 074917b7418c3541458963b3c720fa1546745690 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 9 Feb 2026 09:35:22 -0300 Subject: [PATCH 074/125] refactor(lsp): use same handle file in client and manager (#2168) Signed-off-by: Carlos Alexandro Becker --- internal/lsp/client.go | 21 +-------------------- internal/lsp/manager.go | 28 ++++++++++++++++++---------- 2 files changed, 19 insertions(+), 30 deletions(-) diff --git a/internal/lsp/client.go b/internal/lsp/client.go index c4d80d8af918a202b97d71d5d939338aa3cf1c77..7ba6cc23c6b12f3b54e285a998c67a669b4ff6b5 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -329,26 +329,7 @@ func (c *Client) HandlesFile(path string) bool { 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 - } - - 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) || filetype == string(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) - return false + return handlesFiletype(c.name, c.fileTypes, path) } // OpenFile opens a file in the LSP server. diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index b59819e0d64a592d2c5fd7d9e8c6c9ec8d2fed38..4b70205fc033b52db56a660e9b8f0166cd54bdc5 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -17,8 +17,7 @@ import ( "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/fsext" powernapconfig "github.com/charmbracelet/x/powernap/pkg/config" - "github.com/charmbracelet/x/powernap/pkg/lsp" - "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" + powernap "github.com/charmbracelet/x/powernap/pkg/lsp" "github.com/sourcegraph/jsonrpc2" ) @@ -215,14 +214,25 @@ func resolveServerName(manager *powernapconfig.Manager, name string) string { return name } -func handlesFiletype(server *powernapconfig.ServerConfig, ext string, language protocol.LanguageKind) bool { - for _, ft := range server.FileTypes { - if protocol.LanguageKind(ft) == language || - ft == strings.TrimPrefix(ext, ".") || - "."+ft == ext { +func handlesFiletype(sname string, fileTypes []string, filePath string) bool { + if len(fileTypes) == 0 { + return true + } + + kind := powernap.DetectLanguage(filePath) + name := strings.ToLower(filepath.Base(filePath)) + for _, filetype := range fileTypes { + suffix := strings.ToLower(filetype) + if !strings.HasPrefix(suffix, ".") { + suffix = "." + suffix + } + if strings.HasSuffix(name, suffix) || filetype == string(kind) { + slog.Debug("Handles file", "name", sname, "file", name, "filetype", filetype, "kind", kind) return true } } + + slog.Debug("Doesn't handle file", "name", sname, "file", name) return false } @@ -241,9 +251,7 @@ func hasRootMarkers(dir string, markers []string) bool { } func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool { - language := lsp.DetectLanguage(filePath) - ext := filepath.Ext(filePath) - return handlesFiletype(server, ext, language) && + return handlesFiletype(server.Command, server.FileTypes, filePath) && hasRootMarkers(workDir, server.RootMarkers) } From 6b4249d8292f2270839b5955944b5fe9a0e42508 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:35:37 -0300 Subject: [PATCH 075/125] chore(deps): bump the all group with 2 updates (#2173) 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.32.0 to 4.32.2 - [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/b20883b0cd1f46c72ae0ba6d1090936928f9fa30...45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2) Updates `anchore/scan-action` from 7.3.1 to 7.3.2 - [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/8d2fce09422cd6037e577f4130e9b925e9a37175...7037fa011853d5a11690026fb85feee79f4c946c) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.32.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: anchore/scan-action dependency-version: 7.3.2 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 7291604a5f34c4e1565d5c1a454860c6d25892da..9f1bed1c8ccead5603f6b868e018b4872dbfc1bb 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@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + - uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2 with: languages: ${{ matrix.language }} - - uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 - - uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + - uses: github/codeql-action/autobuild@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2 + - uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2 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@8d2fce09422cd6037e577f4130e9b925e9a37175 # v7.3.1 + - uses: anchore/scan-action@7037fa011853d5a11690026fb85feee79f4c946c # v7.3.2 id: scan with: path: "." fail-build: true severity-cutoff: critical - - uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + - uses: github/codeql-action/upload-sarif@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2 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@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + - uses: github/codeql-action/upload-sarif@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2 with: sarif_file: results.sarif From 16ac9738884d1ce2b207ca3d6b674c4fd4224e38 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:36:09 -0300 Subject: [PATCH 076/125] chore(deps): bump the all group with 3 updates (#2174) Bumps the all group with 3 updates: [github.com/clipperhouse/displaywidth](https://github.com/clipperhouse/displaywidth), [github.com/clipperhouse/uax29/v2](https://github.com/clipperhouse/uax29) and [github.com/posthog/posthog-go](https://github.com/posthog/posthog-go). Updates `github.com/clipperhouse/displaywidth` from 0.9.0 to 0.10.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.9.0...v0.10.0) Updates `github.com/clipperhouse/uax29/v2` from 2.5.0 to 2.6.0 - [Release notes](https://github.com/clipperhouse/uax29/releases) - [Commits](https://github.com/clipperhouse/uax29/compare/v2.5.0...v2.6.0) Updates `github.com/posthog/posthog-go` from 1.9.1 to 1.10.0 - [Release notes](https://github.com/posthog/posthog-go/releases) - [Changelog](https://github.com/PostHog/posthog-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/posthog/posthog-go/compare/v1.9.1...v1.10.0) --- updated-dependencies: - dependency-name: github.com/clipperhouse/displaywidth dependency-version: 0.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: github.com/clipperhouse/uax29/v2 dependency-version: 2.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: github.com/posthog/posthog-go dependency-version: 1.10.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 | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 315c0539463a9edd6b6afde8195baa5067702309..efbe07fe1bcf22ecc030873e591e4a869ce6171a 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.9.0 - github.com/clipperhouse/uax29/v2 v2.5.0 + github.com/clipperhouse/displaywidth v0.10.0 + github.com/clipperhouse/uax29/v2 v2.6.0 github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.1 @@ -49,7 +49,7 @@ require ( github.com/nxadm/tail v1.4.11 github.com/openai/openai-go/v2 v2.7.1 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c - github.com/posthog/posthog-go v1.9.1 + github.com/posthog/posthog-go v1.10.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 diff --git a/go.sum b/go.sum index e6b69ffb19e4d2a1cc2003aa1c26b0fa423fe418..f13b9ecdcf1384f3b4987d5a30039c0f99111b69 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.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= +github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= 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.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= +github.com/clipperhouse/uax29/v2 v2.6.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= @@ -290,8 +290,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posthog/posthog-go v1.9.1 h1:9bkcRnYSvcgMxL2s9QlCnd1DVnm2qWXxWu5o0HSF0xM= -github.com/posthog/posthog-go v1.9.1/go.mod h1:wB3/9Q7d9gGb1P/yf/Wri9VBlbP8oA8z++prRzL5OcY= +github.com/posthog/posthog-go v1.10.0 h1:wfoy7Jfb4LigCoHYyMZoiJmmEoCLOkSaYfDxM/NtCqY= +github.com/posthog/posthog-go v1.10.0/go.mod h1:wB3/9Q7d9gGb1P/yf/Wri9VBlbP8oA8z++prRzL5OcY= 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= From 3b3a3d78c8a329ab9274fe95b47deada3f8a133c Mon Sep 17 00:00:00 2001 From: M1xA Date: Mon, 9 Feb 2026 14:40:35 +0200 Subject: [PATCH 077/125] fix: prevent goroutine orphaning in mcp.Close() and shell.KillAll() (#2159) --- internal/agent/tools/mcp/init.go | 29 +++++++++++++++------------- internal/app/app.go | 8 +++++--- internal/shell/background.go | 32 +++++++++++++------------------ internal/shell/background_test.go | 26 ++++++++++++++++++++++++- 4 files changed, 59 insertions(+), 36 deletions(-) diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index 7fdd2cd0a6477fce7a9dea85473e87e83d8e1a35..7ddc2a7ee44eaad15b1177f98141161359bb6b4c 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -124,26 +124,29 @@ func GetState(name string) (ClientInfo, bool) { // Close closes all MCP clients. This should be called during application shutdown. func Close() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var wg sync.WaitGroup - done := make(chan struct{}, 1) - go func() { - for name, session := range sessions.Seq2() { - wg.Go(func() { - if err := session.Close(); err != nil && + for name, session := range sessions.Seq2() { + wg.Go(func() { + done := make(chan error, 1) + go func() { + done <- session.Close() + }() + select { + case err := <-done: + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) && err.Error() != "signal: killed" { slog.Warn("Failed to shutdown MCP client", "name", name, "error", err) } - }) - } - wg.Wait() - done <- struct{}{} - }() - select { - case <-done: - case <-time.After(5 * time.Second): + case <-ctx.Done(): + } + }) } + wg.Wait() broker.Shutdown() return nil } diff --git a/internal/app/app.go b/internal/app/app.go index 7e16e294c17553a030d412ecde4ad95a90d53ecc..9993e3ee80732a47ad98aa00d23e98a5438bb2b8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -540,14 +540,16 @@ func (app *App) Shutdown() { // Now run remaining cleanup tasks in parallel. var wg sync.WaitGroup + // Shared shutdown context for all timeout-bounded cleanup. + shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second) + defer cancel() + // Kill all background shells. wg.Go(func() { - shell.GetBackgroundShellManager().KillAll() + shell.GetBackgroundShellManager().KillAll(shutdownCtx) }) // Shutdown all LSP clients. - shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second) - defer cancel() wg.Go(func() { app.LSPManager.StopAll(shutdownCtx) }) diff --git a/internal/shell/background.go b/internal/shell/background.go index cb1855836f64bdd56a90802c2bbb939a5a514100..c6a0f81e2c4c0b9de19a599b07f58cf7225d32a2 100644 --- a/internal/shell/background.go +++ b/internal/shell/background.go @@ -191,29 +191,23 @@ func (m *BackgroundShellManager) Cleanup() int { return len(toRemove) } -// KillAll terminates all background shells. -func (m *BackgroundShellManager) KillAll() { +// KillAll terminates all background shells. The provided context bounds how +// long the function waits for each shell to exit. +func (m *BackgroundShellManager) KillAll(ctx context.Context) { shells := slices.Collect(m.shells.Seq()) m.shells.Reset(map[string]*BackgroundShell{}) - done := make(chan struct{}, 1) - go func() { - var wg sync.WaitGroup - for _, shell := range shells { - wg.Go(func() { - shell.cancel() - <-shell.done - }) - } - wg.Wait() - done <- struct{}{} - }() - select { - case <-done: - return - case <-time.After(time.Second * 5): - return + var wg sync.WaitGroup + for _, shell := range shells { + wg.Go(func() { + shell.cancel() + select { + case <-shell.done: + case <-ctx.Done(): + } + }) } + wg.Wait() } // GetOutput returns the current output of a background shell. diff --git a/internal/shell/background_test.go b/internal/shell/background_test.go index 7c521bc1477b07775cffb69f310fa83d710d4634..f3a8cb9f7db442be67fc1ac7f2898fd6d1d2a87e 100644 --- a/internal/shell/background_test.go +++ b/internal/shell/background_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" "time" + + "github.com/stretchr/testify/require" ) func TestBackgroundShellManager_Start(t *testing.T) { @@ -248,7 +250,7 @@ func TestBackgroundShellManager_KillAll(t *testing.T) { } // Kill all shells - manager.KillAll() + manager.KillAll(context.Background()) // Verify all shells are done if !shell1.IsDone() { @@ -280,3 +282,25 @@ func TestBackgroundShellManager_KillAll(t *testing.T) { } } } + +func TestBackgroundShellManager_KillAll_Timeout(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + manager := newBackgroundShellManager() + + // Start a shell that traps signals and ignores cancellation. + _, err := manager.Start(context.Background(), workingDir, nil, "trap '' TERM INT; sleep 60", "") + require.NoError(t, err) + + // Short timeout to test the timeout path. + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + start := time.Now() + manager.KillAll(ctx) + elapsed := time.Since(start) + + // Must return promptly after timeout, not hang for 60 seconds. + require.Less(t, elapsed, 2*time.Second) +} From 722093b9398fb3f5ce00888ad02c5e0bf0619e78 Mon Sep 17 00:00:00 2001 From: kslamph <15257433+kslamph@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:07:38 +0800 Subject: [PATCH 078/125] fix(ui): prevent nil pointer in completions size update (#2162) --- internal/ui/completions/completions.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go index ae130777a9278eb834f2eb544f630a4f51b8b212..e20076b267b1129830f848d5dbff66d869592954 100644 --- a/internal/ui/completions/completions.go +++ b/internal/ui/completions/completions.go @@ -182,6 +182,9 @@ func (c *Completions) updateSize() { width := 0 for i := start; i <= end; i++ { item := c.list.ItemAt(i) + if item == nil { + continue + } s := item.(interface{ Text() string }).Text() width = max(width, ansi.StringWidth(s)) } From 63e009898a0fc6f2f1aa29a2f375ab4ebd579061 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 9 Feb 2026 11:32:01 -0300 Subject: [PATCH 079/125] fix: improving shutdown (#2175) * test: use t.Context() and synctest Signed-off-by: Carlos Alexandro Becker * fix: passing down context to all shutdown funcs Signed-off-by: Carlos Alexandro Becker * perf(lsp): kill all clients on shutdown Signed-off-by: Carlos Alexandro Becker * fix: exit posthog earlier Signed-off-by: Carlos Alexandro Becker * test: fix dirs test Signed-off-by: Carlos Alexandro Becker * test: fix projects test Signed-off-by: Carlos Alexandro Becker * test: fix race Signed-off-by: Carlos Alexandro Becker * Update internal/lsp/manager.go Co-authored-by: Andrey Nering * fix: cleanup Signed-off-by: Carlos Alexandro Becker * test: race Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker Co-authored-by: Andrey Nering --- go.mod | 2 +- go.sum | 4 ++-- internal/agent/tools/mcp/init.go | 5 +---- internal/app/app.go | 22 ++++++++++++++++------ internal/cmd/dirs_test.go | 2 ++ internal/cmd/root.go | 3 --- internal/lsp/client.go | 5 ++++- internal/lsp/manager.go | 22 ++++++++++++++++++++++ internal/projects/projects_test.go | 6 ++++++ internal/shell/background_test.go | 25 ++++++++++++++----------- 10 files changed, 68 insertions(+), 28 deletions(-) diff --git a/go.mod b/go.mod index efbe07fe1bcf22ecc030873e591e4a869ce6171a..0e6f4dbed1319da79c15d72b134582caae6eae3f 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-20260127155452-b72a9a918687 + github.com/charmbracelet/x/powernap v0.0.0-20260209132835-6b065b8ba62c github.com/charmbracelet/x/term v0.2.2 github.com/clipperhouse/displaywidth v0.10.0 github.com/clipperhouse/uax29/v2 v2.6.0 diff --git a/go.sum b/go.sum index f13b9ecdcf1384f3b4987d5a30039c0f99111b69..efa8d982f0b30438196af0de8bf46e501ff45c67 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-20260127155452-b72a9a918687 h1:h1XMgTkpBt9kEJ+9DkARNBXEgaigUQ0cI2Bot7Awnt8= -github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= +github.com/charmbracelet/x/powernap v0.0.0-20260209132835-6b065b8ba62c h1:6E+Y7WQ6Rnw+FmeXoRBtyCBkPcXS0hSMuws6QBr+nyQ= +github.com/charmbracelet/x/powernap v0.0.0-20260209132835-6b065b8ba62c/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/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index 7ddc2a7ee44eaad15b1177f98141161359bb6b4c..e8397915f434072387d92fd59c8842a278709426 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -123,10 +123,7 @@ func GetState(name string) (ClientInfo, bool) { } // Close closes all MCP clients. This should be called during application shutdown. -func Close() error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - +func Close(ctx context.Context) error { var wg sync.WaitGroup for name, session := range sessions.Seq2() { wg.Go(func() { diff --git a/internal/app/app.go b/internal/app/app.go index 9993e3ee80732a47ad98aa00d23e98a5438bb2b8..ba955e311e6a22b89bbe44d64fc7f1bfb01d8850 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -22,6 +22,7 @@ import ( "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/db" + "github.com/charmbracelet/crush/internal/event" "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/format" "github.com/charmbracelet/crush/internal/history" @@ -68,7 +69,7 @@ type App struct { // global context and cleanup functions globalCtx context.Context - cleanupFuncs []func() error + cleanupFuncs []func(context.Context) error } // New initializes a new application instance. @@ -108,7 +109,11 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { go mcp.Initialize(ctx, app.Permissions, cfg) // cleanup database upon app shutdown - app.cleanupFuncs = append(app.cleanupFuncs, conn.Close, mcp.Close) + app.cleanupFuncs = append( + app.cleanupFuncs, + func(context.Context) error { return conn.Close() }, + mcp.Close, + ) // TODO: remove the concept of agent config, most likely. if !cfg.IsConfigured() { @@ -416,7 +421,7 @@ func (app *App) setupEvents() { setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events) setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events) setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events) - cleanupFunc := func() error { + cleanupFunc := func(context.Context) error { cancel() app.serviceEventsWG.Wait() return nil @@ -503,7 +508,7 @@ func (app *App) Subscribe(program *tea.Program) { app.tuiWG.Add(1) tuiCtx, tuiCancel := context.WithCancel(app.globalCtx) - app.cleanupFuncs = append(app.cleanupFuncs, func() error { + app.cleanupFuncs = append(app.cleanupFuncs, func(context.Context) error { slog.Debug("Cancelling TUI message handler") tuiCancel() app.tuiWG.Wait() @@ -544,6 +549,11 @@ func (app *App) Shutdown() { shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second) defer cancel() + // Send exit event + wg.Go(func() { + event.AppExited() + }) + // Kill all background shells. wg.Go(func() { shell.GetBackgroundShellManager().KillAll(shutdownCtx) @@ -551,14 +561,14 @@ func (app *App) Shutdown() { // Shutdown all LSP clients. wg.Go(func() { - app.LSPManager.StopAll(shutdownCtx) + app.LSPManager.KillAll(shutdownCtx) }) // Call all cleanup functions. for _, cleanup := range app.cleanupFuncs { if cleanup != nil { wg.Go(func() { - if err := cleanup(); err != nil { + if err := cleanup(shutdownCtx); err != nil { slog.Error("Failed to cleanup app properly on shutdown", "error", err) } }) diff --git a/internal/cmd/dirs_test.go b/internal/cmd/dirs_test.go index 2d68f45481a61b4ee9cf9ddc31b8d86d8a69a51f..222e833f87b88fb859f54b7f5c4953b58423afaa 100644 --- a/internal/cmd/dirs_test.go +++ b/internal/cmd/dirs_test.go @@ -12,6 +12,8 @@ import ( func init() { os.Setenv("XDG_CONFIG_HOME", "/tmp/fakeconfig") os.Setenv("XDG_DATA_HOME", "/tmp/fakedata") + os.Unsetenv("CRUSH_GLOBAL_CONFIG") + os.Unsetenv("CRUSH_GLOBAL_DATA") } func TestDirs(t *testing.T) { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index cf6fd0909ebfdf1643e2ad4fc2de868a8b1e1c1a..16598f98765b321e6c7e5c9d8e51133800f57aa1 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -106,9 +106,6 @@ crush -y } return nil }, - PostRun: func(cmd *cobra.Command, args []string) { - event.AppExited() - }, } var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(` diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 7ba6cc23c6b12f3b54e285a998c67a669b4ff6b5..0fb73577f62c138abf435a789996a86dbc328993 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -117,7 +117,10 @@ func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol return result, nil } -// Close closes the LSP client. +// Kill kills the client without doing anything else. +func (c *Client) Kill() { c.client.Kill() } + +// Close closes all open files in the client, then the client. func (c *Client) Close(ctx context.Context) error { c.CloseAllFiles(ctx) diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index 4b70205fc033b52db56a660e9b8f0166cd54bdc5..fae462557df045d0f4822acb3e770d6057091f6c 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -255,6 +255,28 @@ func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool hasRootMarkers(workDir, server.RootMarkers) } +// KillAll force-kills all the LSP clients. +// +// This is generally faster than [Manager.StopAll] because it doesn't wait for +// the server to exit gracefully, but it can lead to data loss if the server is +// in the middle of writing something. +// Generally it doesn't matter when shutting down Crush, though. +func (s *Manager) KillAll(context.Context) { + s.mu.Lock() + defer s.mu.Unlock() + + var wg sync.WaitGroup + for name, client := range s.clients.Seq2() { + wg.Go(func() { + defer func() { s.callback(name, client) }() + client.client.Kill() + client.SetServerState(StateStopped) + slog.Debug("Killed LSP client", "name", name) + }) + } + wg.Wait() +} + // StopAll stops all running LSP clients and clears the client map. func (s *Manager) StopAll(ctx context.Context) { s.mu.Lock() diff --git a/internal/projects/projects_test.go b/internal/projects/projects_test.go index 2919410a4f57706d2e42e8cf760cfa8c7df43882..e41ffca74040648315a369f451140aec57bdfb40 100644 --- a/internal/projects/projects_test.go +++ b/internal/projects/projects_test.go @@ -12,6 +12,7 @@ func TestRegisterAndList(t *testing.T) { // Override the projects file path for testing t.Setenv("XDG_DATA_HOME", tmpDir) + t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush")) // Test registering a project err := Register("/home/user/project1", "/home/user/project1/.crush") @@ -61,6 +62,7 @@ func TestRegisterAndList(t *testing.T) { func TestRegisterUpdatesExisting(t *testing.T) { tmpDir := t.TempDir() t.Setenv("XDG_DATA_HOME", tmpDir) + t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush")) // Register a project err := Register("/home/user/project1", "/home/user/project1/.crush") @@ -97,6 +99,7 @@ func TestRegisterUpdatesExisting(t *testing.T) { func TestLoadEmptyFile(t *testing.T) { tmpDir := t.TempDir() t.Setenv("XDG_DATA_HOME", tmpDir) + t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush")) // List before any projects exist projects, err := List() @@ -112,6 +115,7 @@ func TestLoadEmptyFile(t *testing.T) { func TestProjectsFilePath(t *testing.T) { tmpDir := t.TempDir() t.Setenv("XDG_DATA_HOME", tmpDir) + t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush")) expected := filepath.Join(tmpDir, "crush", "projects.json") actual := projectsFilePath() @@ -124,6 +128,7 @@ func TestProjectsFilePath(t *testing.T) { func TestRegisterWithParentDataDir(t *testing.T) { tmpDir := t.TempDir() t.Setenv("XDG_DATA_HOME", tmpDir) + t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush")) // Register a project where .crush is in a parent directory. // e.g., working in /home/user/monorepo/packages/app but .crush is at /home/user/monorepo/.crush @@ -153,6 +158,7 @@ func TestRegisterWithParentDataDir(t *testing.T) { func TestRegisterWithExternalDataDir(t *testing.T) { tmpDir := t.TempDir() t.Setenv("XDG_DATA_HOME", tmpDir) + t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush")) // Register a project where .crush is in a completely different location. // e.g., project at /home/user/project but data stored at /var/data/crush/myproject diff --git a/internal/shell/background_test.go b/internal/shell/background_test.go index f3a8cb9f7db442be67fc1ac7f2898fd6d1d2a87e..62a43514825bd6428e5928ccd704b46b7d9e8b6f 100644 --- a/internal/shell/background_test.go +++ b/internal/shell/background_test.go @@ -14,7 +14,7 @@ func TestBackgroundShellManager_Start(t *testing.T) { t.Skip("Skipping this until I figure out why its flaky") t.Parallel() - ctx := context.Background() + ctx := t.Context() workingDir := t.TempDir() manager := newBackgroundShellManager() @@ -51,7 +51,7 @@ func TestBackgroundShellManager_Start(t *testing.T) { func TestBackgroundShellManager_Get(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() workingDir := t.TempDir() manager := newBackgroundShellManager() @@ -77,7 +77,7 @@ func TestBackgroundShellManager_Get(t *testing.T) { func TestBackgroundShellManager_Kill(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() workingDir := t.TempDir() manager := newBackgroundShellManager() @@ -119,7 +119,7 @@ func TestBackgroundShellManager_KillNonExistent(t *testing.T) { func TestBackgroundShell_IsDone(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() workingDir := t.TempDir() manager := newBackgroundShellManager() @@ -142,7 +142,7 @@ func TestBackgroundShell_IsDone(t *testing.T) { func TestBackgroundShell_WithBlockFuncs(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() workingDir := t.TempDir() manager := newBackgroundShellManager() @@ -180,7 +180,7 @@ func TestBackgroundShellManager_List(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() workingDir := t.TempDir() manager := newBackgroundShellManager() @@ -224,7 +224,7 @@ func TestBackgroundShellManager_List(t *testing.T) { func TestBackgroundShellManager_KillAll(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() workingDir := t.TempDir() manager := newBackgroundShellManager() @@ -250,7 +250,7 @@ func TestBackgroundShellManager_KillAll(t *testing.T) { } // Kill all shells - manager.KillAll(context.Background()) + manager.KillAll(t.Context()) // Verify all shells are done if !shell1.IsDone() { @@ -286,19 +286,22 @@ func TestBackgroundShellManager_KillAll(t *testing.T) { func TestBackgroundShellManager_KillAll_Timeout(t *testing.T) { t.Parallel() + // XXX: can't use synctest here - causes --race to trip. + workingDir := t.TempDir() manager := newBackgroundShellManager() // Start a shell that traps signals and ignores cancellation. - _, err := manager.Start(context.Background(), workingDir, nil, "trap '' TERM INT; sleep 60", "") + _, err := manager.Start(t.Context(), workingDir, nil, "trap '' TERM INT; sleep 60", "") require.NoError(t, err) // Short timeout to test the timeout path. - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + t.Cleanup(cancel) start := time.Now() manager.KillAll(ctx) + elapsed := time.Since(start) // Must return promptly after timeout, not hang for 60 seconds. From 7d00857e4c549ddc067a30c16e97e580bb3c0d50 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 9 Feb 2026 15:37:50 -0300 Subject: [PATCH 081/125] perf: track and start lsp on command (#2176) Signed-off-by: Carlos Alexandro Becker --- internal/ui/model/ui.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 77dfc9a726f466fafaacede483438629e293ae2f..d4f2d4f7309d7f1f4e68546b96c49009bf6f8624 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2723,10 +2723,13 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea. } ctx := context.Background() - for _, path := range m.sessionFileReads { - m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path) - m.com.App.LSPManager.Start(ctx, path) - } + cmds = append(cmds, func() tea.Msg { + for _, path := range m.sessionFileReads { + m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path) + m.com.App.LSPManager.Start(ctx, path) + } + return nil + }) // Capture session ID to avoid race with main goroutine updating m.session. sessionID := m.session.ID From 340ce206c0248d1a226649b19e5e2c2adaab006f Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 9 Feb 2026 15:03:06 -0300 Subject: [PATCH 082/125] fix(ui): fix help wrapping on dialogs --- internal/ui/dialog/common.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/ui/dialog/common.go b/internal/ui/dialog/common.go index ca5dcb704d42dc6475369bf6d8020f707e16190e..339b9033dbf384760f3a1d42facb1823ba5a66ba 100644 --- a/internal/ui/dialog/common.go +++ b/internal/ui/dialog/common.go @@ -136,9 +136,10 @@ func (rc *RenderContext) Render() string { if rc.Gap > 0 { parts = append(parts, make([]string, rc.Gap)...) } + helpWidth := rc.Width - dialogStyle.GetHorizontalFrameSize() helpStyle := rc.Styles.Dialog.HelpView - helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize()) - helpView := ansi.Truncate(helpStyle.Render(rc.Help), rc.Width, "") + helpStyle = helpStyle.Width(helpWidth) + helpView := ansi.Truncate(helpStyle.Render(rc.Help), helpWidth-1, "") parts = append(parts, helpView) } From 1615de50386d14d80b022a3677a6bbb475a80106 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 9 Feb 2026 16:46:28 -0300 Subject: [PATCH 083/125] feat: add ability to re-authenticate / edit api key --- internal/ui/dialog/actions.go | 7 ++--- internal/ui/dialog/models.go | 50 +++++++++++++++++++++++------------ internal/ui/model/ui.go | 4 +-- 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 2776d886d49d979fd7673fb830dfa9d9a11f9006..5c96f1c96111222a270a4529d39bfaac4162205c 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -36,9 +36,10 @@ type ActionSelectSession struct { // ActionSelectModel is a message indicating a model has been selected. type ActionSelectModel struct { - Provider catwalk.Provider - Model config.SelectedModel - ModelType config.SelectedModelType + Provider catwalk.Provider + Model config.SelectedModel + ModelType config.SelectedModelType + ReAuthenticate bool } // Messages for commands diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 2f729e19995790fc1bb57fbea4b80191195df8da..d77d8952fc1d9a413d35cc3a4c3ae315e0881c60 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -70,7 +70,7 @@ const ( // ModelsID is the identifier for the model selection dialog. const ModelsID = "models" -const defaultModelsDialogMaxWidth = 70 +const defaultModelsDialogMaxWidth = 73 // Models represents a model selection dialog. type Models struct { @@ -84,6 +84,7 @@ type Models struct { Tab key.Binding UpDown key.Binding Select key.Binding + Edit key.Binding Next key.Binding Previous key.Binding Close key.Binding @@ -124,6 +125,10 @@ func NewModels(com *common.Common, isOnboarding bool) (*Models, error) { key.WithKeys("enter", "ctrl+y"), key.WithHelp("enter", "confirm"), ) + m.keyMap.Edit = key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("ctrl+e", "edit"), + ) m.keyMap.UpDown = key.NewBinding( key.WithKeys("up", "down"), key.WithHelp("↑/↓", "choose"), @@ -181,7 +186,7 @@ func (m *Models) HandleMsg(msg tea.Msg) Action { } m.list.SelectNext() m.list.ScrollToSelected() - case key.Matches(msg, m.keyMap.Select): + case key.Matches(msg, m.keyMap.Select, m.keyMap.Edit): selectedItem := m.list.SelectedItem() if selectedItem == nil { break @@ -192,10 +197,13 @@ func (m *Models) HandleMsg(msg tea.Msg) Action { break } + isEdit := key.Matches(msg, m.keyMap.Edit) + return ActionSelectModel{ - Provider: modelItem.prov, - Model: modelItem.SelectedModel(), - ModelType: modelItem.SelectedModelType(), + Provider: modelItem.prov, + Model: modelItem.SelectedModel(), + ModelType: modelItem.SelectedModelType(), + ReAuthenticate: isEdit, } case key.Matches(msg, m.keyMap.Tab): if m.isOnboarding { @@ -309,27 +317,35 @@ func (m *Models) ShortHelp() []key.Binding { m.keyMap.Select, } } - return []key.Binding{ + h := []key.Binding{ m.keyMap.UpDown, m.keyMap.Tab, m.keyMap.Select, - m.keyMap.Close, } + if m.isSelectedConfigured() { + h = append(h, m.keyMap.Edit) + } + h = append(h, m.keyMap.Close) + return h } // FullHelp returns the full help view. func (m *Models) FullHelp() [][]key.Binding { - return [][]key.Binding{ - { - m.keyMap.Select, - m.keyMap.Next, - m.keyMap.Previous, - m.keyMap.Tab, - }, - { - m.keyMap.Close, - }, + return [][]key.Binding{m.ShortHelp()} +} + +func (m *Models) isSelectedConfigured() bool { + selectedItem := m.list.SelectedItem() + if selectedItem == nil { + return false + } + modelItem, ok := selectedItem.(*ModelItem) + if !ok { + return false } + providerID := string(modelItem.prov.ID) + _, isConfigured := m.com.Config().Providers.Get(providerID) + return isConfigured } // setProviderItems sets the provider items in the list. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index d4f2d4f7309d7f1f4e68546b96c49009bf6f8624..1088212a91ef0810bce7c5316a1bb33cad204b46 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1266,11 +1266,11 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { ) // Attempt to import GitHub Copilot tokens from VSCode if available. - if isCopilot && !isConfigured() { + if isCopilot && !isConfigured() && !msg.ReAuthenticate { m.com.Config().ImportCopilot() } - if !isConfigured() { + if !isConfigured() || msg.ReAuthenticate { m.dialog.CloseDialog(dialog.ModelsID) if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil { cmds = append(cmds, cmd) From 8c9eb4718f0767549a13b895e61cd6b7882d7128 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 9 Feb 2026 20:45:47 -0500 Subject: [PATCH 085/125] docs: update LICENSE copyright --- LICENSE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE.md b/LICENSE.md index 3023931cd8b79d4d0ebf8061e08191df6a14709a..950d0d85cee17e533b53572b19ba34d689d42680 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -6,7 +6,7 @@ FSL-1.1-MIT ## Notice -Copyright 2025 Charmbracelet, Inc +Copyright 2025-2026 Charmbracelet, Inc. ## Terms and Conditions From d391ea85ed2f43d8e1290a2c64269c0782a6185a Mon Sep 17 00:00:00 2001 From: M1xA Date: Tue, 10 Feb 2026 14:56:25 +0200 Subject: [PATCH 086/125] fix(mcp): cancel context on MCP session close to prevent leak (#2157) * fix(mcp): cancel context on MCP session close to prevent leak * refactor: rename unexported mcpSession to exported ClientSession --- internal/agent/tools/mcp/init.go | 26 +++++++++++++----- internal/agent/tools/mcp/init_test.go | 38 +++++++++++++++++++++++++++ internal/agent/tools/mcp/prompts.go | 2 +- internal/agent/tools/mcp/resources.go | 2 +- internal/agent/tools/mcp/tools.go | 2 +- 5 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 internal/agent/tools/mcp/init_test.go diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index e8397915f434072387d92fd59c8842a278709426..f8cfe0ce84bf7b1987496607d42753b8ca72263f 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -38,8 +38,22 @@ func parseLevel(level mcp.LoggingLevel) slog.Level { } } +// ClientSession wraps an mcp.ClientSession with a context cancel function so +// that the context created during session establishment is properly cleaned up +// on close. +type ClientSession struct { + *mcp.ClientSession + cancel context.CancelFunc +} + +// Close cancels the session context and then closes the underlying session. +func (s *ClientSession) Close() error { + s.cancel() + return s.ClientSession.Close() +} + var ( - sessions = csync.NewMap[string, *mcp.ClientSession]() + sessions = csync.NewMap[string, *ClientSession]() states = csync.NewMap[string, ClientInfo]() broker = pubsub.NewBroker[Event]() initOnce sync.Once @@ -102,7 +116,7 @@ type ClientInfo struct { Name string State State Error error - Client *mcp.ClientSession + Client *ClientSession Counts Counts ConnectedAt time.Time } @@ -239,7 +253,7 @@ func WaitForInit(ctx context.Context) error { } } -func getOrRenewClient(ctx context.Context, cfg *config.Config, name string) (*mcp.ClientSession, error) { +func getOrRenewClient(ctx context.Context, cfg *config.Config, name string) (*ClientSession, error) { sess, ok := sessions.Get(name) if !ok { return nil, fmt.Errorf("mcp '%s' not available", name) @@ -268,7 +282,7 @@ func getOrRenewClient(ctx context.Context, cfg *config.Config, name string) (*mc } // updateState updates the state of an MCP client and publishes an event -func updateState(name string, state State, err error, client *mcp.ClientSession, counts Counts) { +func updateState(name string, state State, err error, client *ClientSession, counts Counts) { info := ClientInfo{ Name: name, State: state, @@ -294,7 +308,7 @@ func updateState(name string, state State, err error, client *mcp.ClientSession, }) } -func createSession(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*mcp.ClientSession, error) { +func createSession(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*ClientSession, error) { timeout := mcpTimeout(m) mcpCtx, cancel := context.WithCancel(ctx) cancelTimer := time.AfterFunc(timeout, cancel) @@ -352,7 +366,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve cancelTimer.Stop() slog.Debug("MCP client initialized", "name", name) - return session, nil + return &ClientSession{session, cancel}, nil } // maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail diff --git a/internal/agent/tools/mcp/init_test.go b/internal/agent/tools/mcp/init_test.go new file mode 100644 index 0000000000000000000000000000000000000000..94958593750852d30ff96734ada23671252e508e --- /dev/null +++ b/internal/agent/tools/mcp/init_test.go @@ -0,0 +1,38 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +func TestMCPSession_CancelOnClose(t *testing.T) { + defer goleak.VerifyNone(t) + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server"}, nil) + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + defer serverSession.Close() + + ctx, cancel := context.WithCancel(context.Background()) + + client := mcp.NewClient(&mcp.Implementation{Name: "crush-test"}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + + sess := &ClientSession{clientSession, cancel} + + // Verify the context is not cancelled before close. + require.NoError(t, ctx.Err()) + + err = sess.Close() + require.NoError(t, err) + + // After Close, the context must be cancelled. + require.ErrorIs(t, ctx.Err(), context.Canceled) +} diff --git a/internal/agent/tools/mcp/prompts.go b/internal/agent/tools/mcp/prompts.go index 76338b4a8e349c9177ecaa216be217e241ec402d..2b39d5dc2db43aff418c3dd7561edbcebd6af865 100644 --- a/internal/agent/tools/mcp/prompts.go +++ b/internal/agent/tools/mcp/prompts.go @@ -67,7 +67,7 @@ func RefreshPrompts(ctx context.Context, name string) { updateState(name, StateConnected, nil, session, prev.Counts) } -func getPrompts(ctx context.Context, c *mcp.ClientSession) ([]*Prompt, error) { +func getPrompts(ctx context.Context, c *ClientSession) ([]*Prompt, error) { if c.InitializeResult().Capabilities.Prompts == nil { return nil, nil } diff --git a/internal/agent/tools/mcp/resources.go b/internal/agent/tools/mcp/resources.go index 92f6c83836181a8441d35431f900f5c68334a9eb..912651f0eb4d5c8cf3999cc1fb7f6027cd9bcd52 100644 --- a/internal/agent/tools/mcp/resources.go +++ b/internal/agent/tools/mcp/resources.go @@ -75,7 +75,7 @@ func RefreshResources(ctx context.Context, name string) { updateState(name, StateConnected, nil, session, prev.Counts) } -func getResources(ctx context.Context, c *mcp.ClientSession) ([]*Resource, error) { +func getResources(ctx context.Context, c *ClientSession) ([]*Resource, error) { if c.InitializeResult().Capabilities.Resources == nil { return nil, nil } diff --git a/internal/agent/tools/mcp/tools.go b/internal/agent/tools/mcp/tools.go index da4b463bbc850ea8bfa0c3400defecf05507951d..b6e208f7ccb3363bee0a0b60ef56c103ad9cd41b 100644 --- a/internal/agent/tools/mcp/tools.go +++ b/internal/agent/tools/mcp/tools.go @@ -128,7 +128,7 @@ func RefreshTools(ctx context.Context, cfg *config.Config, name string) { updateState(name, StateConnected, nil, session, prev.Counts) } -func getTools(ctx context.Context, session *mcp.ClientSession) ([]*Tool, error) { +func getTools(ctx context.Context, session *ClientSession) ([]*Tool, error) { // Always call ListTools to get the actual available tools. // The InitializeResult Capabilities.Tools field may be an empty object {}, // which is valid per MCP spec, but we still need to call ListTools to discover tools. From fca69ad22d4ea8b4f1ec0db7f6dba73299984dde Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:39:25 -0300 Subject: [PATCH 087/125] chore(legal): @portertech 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 2e830964060397fee2517963487fe4513020bfc8..2256f849c9b0ecba1833dfe26ef91e9109c20375 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1223,6 +1223,14 @@ "created_at": "2026-02-08T10:27:09Z", "repoId": 987670088, "pullRequestNo": 2165 + }, + { + "name": "portertech", + "id": 149630, + "comment_id": 3878650318, + "created_at": "2026-02-10T15:39:14Z", + "repoId": 987670088, + "pullRequestNo": 2183 } ] } \ No newline at end of file From 1f42acfb37e8f336cf6130f23c290638ab98dd48 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 10 Feb 2026 14:00:20 -0300 Subject: [PATCH 088/125] docs(readme): mention subscriptions (#2184) Co-authored-by: Christian Rocha --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 76d9daefc3fecbdf889a40ddfa0d3f2ea0e4cb0f..8e9f145434a04c749c223166676bdcc323712c75 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,16 @@ That said, you can also set environment variables for preferred providers. | `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) | | `AZURE_OPENAI_API_VERSION` | Azure OpenAI models | +### Subscriptions + +If you prefer subscription-based usage, here are some plans that work well in +Crush: + +- [Synthetic](https://synthetic.new/pricing) +- [GLM Coding Plan](https://z.ai/subscribe) +- [Kimi Code](https://www.kimi.com/membership/pricing) +- [MiniMax Coding Plan](https://platform.minimax.io/subscribe/coding-plan) + ### By the Way Is there a provider you’d like to see in Crush? Is there an existing model that needs an update? From db0ce0bf185655597443be0f7539231c6b687bfe Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 10 Feb 2026 15:17:22 -0300 Subject: [PATCH 089/125] chore: update fantasy (#2186) --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 0e6f4dbed1319da79c15d72b134582caae6eae3f..3c5d732389a9221ceb940873a659b497b6c695ec 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20260209074636-30878e43d7b0 charm.land/catwalk v0.17.1 - charm.land/fantasy v0.7.1 + charm.land/fantasy v0.7.2 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 @@ -129,7 +129,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // 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/jsonschema v0.6.10 // 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 @@ -174,12 +174,12 @@ require ( golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/image v0.34.0 // indirect golang.org/x/mod v0.32.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.40.0 // indirect 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.44.0 // indirect + google.golang.org/genai v1.45.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 efa8d982f0b30438196af0de8bf46e501ff45c67..6f9b10920535d8ad22a3c0786d2a3a3b5b68dd21 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.0.0-rc.2.0.20260209074636-30878e43d7b0 h1:HAbpM9TPjZM charm.land/bubbletea/v2 v2.0.0-rc.2.0.20260209074636-30878e43d7b0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/catwalk v0.17.1 h1:UsHvBi3S7CxONiIZTWKTXM+H9qla8I0fCb/SVru33ms= charm.land/catwalk v0.17.1/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64= -charm.land/fantasy v0.7.1 h1:JOCYeLz32PM11y1u08/YgWl3LfPwhjOIuoyjBXjFofI= -charm.land/fantasy v0.7.1/go.mod h1:zv8Utaob4b9rSPp2ruH515rx7oN+l66gv6RshvwHnww= +charm.land/fantasy v0.7.2 h1:OUBgbs7hllZE7rpJP9SzdsGE/hMCm+mr11iEIqU02hE= +charm.land/fantasy v0.7.2/go.mod h1:vH6F5eYqaxgNEvDQdXRsOsfvoRyT3f/uJngPNJmcDmw= 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= @@ -224,8 +224,8 @@ github.com/kaptinlin/go-i18n v0.2.3 h1:jyN/YOXXLcnGRBLdU+a8+6782B97fWE5aQqAHtvvk 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/jsonschema v0.6.10 h1:CYded7nrwVu7pU1GaIjtd9dSzgqZjh7+LTKFaWqS08I= +github.com/kaptinlin/jsonschema v0.6.10/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= @@ -416,8 +416,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -484,8 +484,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.44.0 h1:+nn8oXANzrpHsWxGfZz2IySq0cFPiepqFvgMFofK8vw= -google.golang.org/genai v1.44.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genai v1.45.0 h1:s80ZpS42XW0zu/ogiOtenCio17nJ7reEFJjoCftukpA= +google.golang.org/genai v1.45.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= From f962087445719a88cdf6371fc977ea30acecf615 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 10 Feb 2026 11:06:43 -0300 Subject: [PATCH 090/125] chore(taskfile): avoid compiling when not needed --- .gitignore | 1 + Taskfile.yaml | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 008dcff3153d850de53e4e792fb320355f0009ea..c0bed181bd8e809c3eb461b7b67778272f556911 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ Thumbs.db manpages/ completions/crush.*sh .prettierignore +.task diff --git a/Taskfile.yaml b/Taskfile.yaml index bff27387d6be353ccd02cf6437b4acafb30334c9..6be3d61cc48f23d2c6b7ecc8befc529a53a709a5 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -46,8 +46,11 @@ tasks: LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}' cmds: - "go build {{if .RACE}}-race{{end}} {{.LDFLAGS}} ." + sources: + - ./**/*.go + - go.mod generates: - - crush + - crush{{exeExt}} run: desc: Run build @@ -91,6 +94,9 @@ tasks: cmds: - task: fetch-tags - go install {{.LDFLAGS}} -v . + sources: + - ./**/*.go + - go.mod profile:cpu: desc: 10s CPU profile From 9e4f8e0a6a0b7ba331a4b4451e902dfd8859b838 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 10 Feb 2026 11:07:43 -0300 Subject: [PATCH 091/125] chore(taskfile): add `run:catwalk` task to run with local catwalk --- Taskfile.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Taskfile.yaml b/Taskfile.yaml index 6be3d61cc48f23d2c6b7ecc8befc529a53a709a5..b14a0fc558948d2991709fad4b2dd2a32dfa8cd4 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -58,6 +58,14 @@ tasks: - task: build - "./crush {{.CLI_ARGS}} {{if .RACE}}2>race.log{{end}}" + run:catwalk: + desc: Run build with local Catwalk + env: + CATWALK_URL: http://localhost:8080 + cmds: + - task: build + - ./crush {{.CLI_ARGS}} + test: desc: Run tests cmds: From 29800c488955f57c0fd52a9463b9ccf0b7473c4c Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 10 Feb 2026 11:08:13 -0300 Subject: [PATCH 092/125] chore(taskfile): add `modernize` task --- Taskfile.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Taskfile.yaml b/Taskfile.yaml index b14a0fc558948d2991709fad4b2dd2a32dfa8cd4..b977ba93af2920c3f401f6c362ddb26161887ec4 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -88,6 +88,11 @@ tasks: cmds: - prettier --write internal/cmd/stats/index.html internal/cmd/stats/index.css internal/cmd/stats/index.js + modernize: + desc: Run modernize + cmds: + - go run golang.org/x/tools/go/analysis/passes/modernize/cmd/modernize@latest -fix -test ./... + dev: desc: Run with profiling enabled env: From 5e23ecdb4da522af4db2251b96d92e0c368389df Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 10 Feb 2026 11:08:36 -0300 Subject: [PATCH 093/125] chore: run `modernize` --- internal/agent/tools/search.go | 4 ++-- internal/config/config.go | 4 ++-- internal/csync/value_test.go | 6 ++---- internal/shell/shell.go | 4 ++-- internal/ui/model/chat.go | 5 +---- 5 files changed, 9 insertions(+), 14 deletions(-) diff --git a/internal/agent/tools/search.go b/internal/agent/tools/search.go index 9df7be8764ab952a23f25d624f72748696a86aac..8d21162001e129f2f614e56b1288bad89904f4c0 100644 --- a/internal/agent/tools/search.go +++ b/internal/agent/tools/search.go @@ -172,8 +172,8 @@ func getTextContent(n *html.Node) string { func cleanDuckDuckGoURL(rawURL string) string { if strings.HasPrefix(rawURL, "//duckduckgo.com/l/?uddg=") { - if idx := strings.Index(rawURL, "uddg="); idx != -1 { - encoded := rawURL[idx+5:] + if _, after, ok := strings.Cut(rawURL, "uddg="); ok { + encoded := after if ampIdx := strings.Index(encoded, "&"); ampIdx != -1 { encoded = encoded[:ampIdx] } diff --git a/internal/config/config.go b/internal/config/config.go index 07608f4ff59abda3347b032673b9bb2c3705c2e5..fd76e4f8a8639ececc8e8452c75bc6178e4b8cff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -350,7 +350,7 @@ type Agent struct { } type Tools struct { - Ls ToolLs `json:"ls,omitempty"` + Ls ToolLs `json:"ls"` } type ToolLs struct { @@ -383,7 +383,7 @@ type Config struct { Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"` - Tools Tools `json:"tools,omitempty" jsonschema:"description=Tool configurations"` + Tools Tools `json:"tools" jsonschema:"description=Tool configurations"` Agents map[string]Agent `json:"-"` diff --git a/internal/csync/value_test.go b/internal/csync/value_test.go index 3fa41d85144ea9373c7d440238c0321f52286330..2d0243a3b0ae8f71802469496c35b5d4a50d260b 100644 --- a/internal/csync/value_test.go +++ b/internal/csync/value_test.go @@ -83,11 +83,9 @@ func TestValue_ConcurrentAccess(t *testing.T) { // Concurrent readers. for range 100 { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { _ = v.Get() - }() + }) } wg.Wait() diff --git a/internal/shell/shell.go b/internal/shell/shell.go index ced8da26ed4e837b08e66152e9aafb2cc029c0d1..d8dde82a0077d3be5cd19c2714e5a1a5097d015c 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -207,8 +207,8 @@ func splitArgsFlags(parts []string) (args []string, flags []string) { if strings.HasPrefix(part, "-") { // Extract flag name before '=' if present flag := part - if idx := strings.IndexByte(part, '='); idx != -1 { - flag = part[:idx] + if before, _, ok := strings.Cut(part, "="); ok { + flag = before } flags = append(flags, flag) } else { diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 00a17ecfc5042dd42f4d24682b135667d1345386..a424bd1053134496688d422b0ee19aef3a0b4e35 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -745,10 +745,7 @@ func (m *Chat) selectWord(itemIdx, x, itemY int) { // 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 - } + contentX := max(x-offset, 0) line := ansi.Strip(lines[itemY]) startCol, endCol := findWordBoundaries(line, contentX) From d329ad5558c5ed967fda50ed4dd711ecc977601a Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 10 Feb 2026 13:26:46 -0300 Subject: [PATCH 094/125] chore(taskfile): add `run:onboarding` to test onboarding flow --- Taskfile.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Taskfile.yaml b/Taskfile.yaml index b977ba93af2920c3f401f6c362ddb26161887ec4..2d5462a63ea12468bbaa574454038eb105146867 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -66,6 +66,16 @@ tasks: - task: build - ./crush {{.CLI_ARGS}} + run:onboarding: + desc: Run build with custom config to test onboarding + env: + CRUSH_GLOBAL_DATA: tmp/onboarding/data + CRUSH_GLOBAL_CONFIG: tmp/onboarding/config + cmds: + - task: build + - rm -rf tmp/onboarding + - ./crush {{.CLI_ARGS}} + test: desc: Run tests cmds: From 7a83144f8629c26d2e45eb28ebfefe9710d91eef Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 10 Feb 2026 14:13:14 -0300 Subject: [PATCH 095/125] chore: update `AGENTS.md`, mention `x/ansi` package --- AGENTS.md | 7 ++++--- internal/ui/AGENTS.md | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 654f1cd0a7fe1cbb50a3026f86f31b68e04f8043..0691c3b7bdef8bdd1adfde6cb75e5fd0e2e01d60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,17 +7,18 @@ - **Update Golden Files**: `go test ./... -update` (regenerates .golden files when test output changes) - Update specific package: `go test ./internal/tui/components/core -update` (in this case, we're updating "core") - **Lint**: `task lint:fix` -- **Format**: `task fmt` (gofumpt -w .) +- **Format**: `task fmt` (`gofumpt -w .`) +- **Modernize**: `task modernize` (runs `modernize` which make code simplifications) - **Dev**: `task dev` (runs with profiling enabled) ## Code Style Guidelines -- **Imports**: Use goimports formatting, group stdlib, external, internal packages +- **Imports**: Use `goimports` formatting, group stdlib, external, internal packages - **Formatting**: Use gofumpt (stricter than gofmt), enabled in golangci-lint - **Naming**: Standard Go conventions - PascalCase for exported, camelCase for unexported - **Types**: Prefer explicit types, use type aliases for clarity (e.g., `type AgentName string`) - **Error handling**: Return errors explicitly, use `fmt.Errorf` for wrapping -- **Context**: Always pass context.Context as first parameter for operations +- **Context**: Always pass `context.Context` as first parameter for operations - **Interfaces**: Define interfaces in consuming packages, keep them small and focused - **Structs**: Use struct embedding for composition, group related fields - **Constants**: Use typed constants with iota for enums, group in const blocks diff --git a/internal/ui/AGENTS.md b/internal/ui/AGENTS.md index 9bb2ceaf20da8b75df3a40390111b2a8be7f94c2..4140bd60820c2799849e2cd6beccaf36d6ef93e2 100644 --- a/internal/ui/AGENTS.md +++ b/internal/ui/AGENTS.md @@ -1,15 +1,25 @@ # UI Development Instructions ## General Guidelines + - Never use commands to send messages when you can directly mutate children or state. - Keep things simple; do not overcomplicate. - Create files if needed to separate logic; do not nest models. - Never do IO or expensive work in `Update`; always use a `tea.Cmd`. - Never change the model state inside of a command use messages and than update the state in the main loop +- Use the `github.com/charmbracelet/x/ansi` package for any string manipulation + that might involves ANSI codes. Do not manipulate ANSI strings at byte level! + Some useful functions: + * `ansi.Cut` + * `ansi.StringWidth` + * `ansi.Strip` + * `ansi.Truncate` + ## Architecture ### Main Model (`model/ui.go`) + Keep most of the logic and state in the main model. This is where: - Message routing happens - Focus and UI state is managed @@ -17,35 +27,42 @@ Keep most of the logic and state in the main model. This is where: - Dialogs are orchestrated ### Components Should Be Dumb + Components should not handle bubbletea messages directly. Instead: - Expose methods for state changes - Return `tea.Cmd` from methods when side effects are needed - Handle their own rendering via `Render(width int) string` ### Chat Logic (`model/chat.go`) + Most chat-related logic belongs here. Individual chat items in `chat/` should be simple renderers that cache their output and invalidate when data changes (see `cachedMessageItem` in `chat/messages.go`). ## Key Patterns ### Composition Over Inheritance + Use struct embedding for shared behaviors. See `chat/messages.go` for examples of reusable embedded structs for highlighting, caching, and focus. ### Interfaces + - List item interfaces are in `list/item.go` - Chat message interfaces are in `chat/messages.go` - Dialog interface is in `dialog/dialog.go` ### Styling + - All styles are defined in `styles/styles.go` - Access styles via `*common.Common` passed to components - Use semantic color fields rather than hardcoded colors ### Dialogs + - Implement the dialog interface in `dialog/dialog.go` - Return message types from `Update()` to signal actions to the main model - Use the overlay system for managing dialog lifecycle ## File Organization + - `model/` - Main UI model and major components (chat, sidebar, etc.) - `chat/` - Chat message item types and renderers - `dialog/` - Dialog implementations @@ -56,6 +73,7 @@ Use struct embedding for shared behaviors. See `chat/messages.go` for examples o - `logo/` - Logo rendering ## Common Gotchas + - Always account for padding/borders in width calculations - Use `tea.Batch()` when returning multiple commands - Pass `*common.Common` to components that need styles or app access From 82b7aec40ebf2f434f4c95c1d21abfdbafafe6f1 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 10 Feb 2026 15:19:28 -0300 Subject: [PATCH 096/125] chore: add `omitempty` back as `omitzero` --- internal/config/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index fd76e4f8a8639ececc8e8452c75bc6178e4b8cff..1d0aba7522948263cfac8aa3ef185749cfcc425f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -350,7 +350,7 @@ type Agent struct { } type Tools struct { - Ls ToolLs `json:"ls"` + Ls ToolLs `json:"ls,omitzero"` } type ToolLs struct { @@ -383,7 +383,7 @@ type Config struct { Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"` - Tools Tools `json:"tools" jsonschema:"description=Tool configurations"` + Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"` Agents map[string]Agent `json:"-"` From 09cea778fc09a6dbee7dd4a3e8e5111b2ace0952 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:27:37 +0000 Subject: [PATCH 097/125] chore: auto-update files --- schema.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/schema.json b/schema.json index c8d2482079f294b6499810c34c312f0e1729d929..f54df0996a5a763a9265bb51efb1d70c29780d63 100644 --- a/schema.json +++ b/schema.json @@ -92,7 +92,10 @@ } }, "additionalProperties": false, - "type": "object" + "type": "object", + "required": [ + "tools" + ] }, "LSPConfig": { "properties": { @@ -716,7 +719,10 @@ } }, "additionalProperties": false, - "type": "object" + "type": "object", + "required": [ + "ls" + ] } } } From 48157c6c4eac6af31bda49697f0ef26b90d88df6 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 10 Feb 2026 15:31:27 -0300 Subject: [PATCH 098/125] fix(sqlite): increase busy timeout (#2181) Also refactor so we have the same pragmas on both drivers. I couldn't reproduce OP's issue, but they're likely trying to use many Crush instances at the same time, which may cause this. The timeout was 5s only, so it was kind of easy to hit under load, I presume. Upping it to 30s should improve that. AFAIK there's no much else we can do. See https://www.sqlite.org/rescode.html#busy Closes #2129 Signed-off-by: Carlos Alexandro Becker --- internal/db/connect.go | 10 ++++++++++ internal/db/connect_modernc.go | 11 ++++------- internal/db/connect_ncruces.go | 20 ++++++-------------- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/internal/db/connect.go b/internal/db/connect.go index 20f0c3f31b1506e32ed9d53327d839ac7616bbc9..231a4d079952be22438a2c09d764a6bb81d0d611 100644 --- a/internal/db/connect.go +++ b/internal/db/connect.go @@ -10,6 +10,16 @@ import ( "github.com/pressly/goose/v3" ) +var pragmas = map[string]string{ + "foreign_keys": "ON", + "journal_mode": "WAL", + "page_size": "4096", + "cache_size": "-8000", + "synchronous": "NORMAL", + "secure_delete": "ON", + "busy_timeout": "30000", +} + // Connect opens a SQLite database connection and runs migrations. func Connect(ctx context.Context, dataDir string) (*sql.DB, error) { if dataDir == "" { diff --git a/internal/db/connect_modernc.go b/internal/db/connect_modernc.go index 303c4e9a1108562d5060699381dcd9d8c9088d8a..39c7faa42516297d4df497821baa0be56835be15 100644 --- a/internal/db/connect_modernc.go +++ b/internal/db/connect_modernc.go @@ -14,18 +14,15 @@ func openDB(dbPath string) (*sql.DB, error) { // Set pragmas for better performance via _pragma query params. // Format: _pragma=name(value) params := url.Values{} - params.Add("_pragma", "foreign_keys(on)") - params.Add("_pragma", "journal_mode(WAL)") - params.Add("_pragma", "page_size(4096)") - params.Add("_pragma", "cache_size(-8000)") - params.Add("_pragma", "synchronous(NORMAL)") - params.Add("_pragma", "secure_delete(on)") - params.Add("_pragma", "busy_timeout(5000)") + for name, value := range pragmas { + params.Add("_pragma", fmt.Sprintf("%s(%s)", name, value)) + } dsn := fmt.Sprintf("file:%s?%s", dbPath, params.Encode()) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } + return db, nil } diff --git a/internal/db/connect_ncruces.go b/internal/db/connect_ncruces.go index ceeb7233a45fff443c13ae7a8dccf740dbd5b782..4832398063b1fa1cd1ae6d30c89c75b286fab2ed 100644 --- a/internal/db/connect_ncruces.go +++ b/internal/db/connect_ncruces.go @@ -12,21 +12,12 @@ import ( ) func openDB(dbPath string) (*sql.DB, error) { - // Set pragmas for better performance. - pragmas := []string{ - "PRAGMA foreign_keys = ON;", - "PRAGMA journal_mode = WAL;", - "PRAGMA page_size = 4096;", - "PRAGMA cache_size = -8000;", - "PRAGMA synchronous = NORMAL;", - "PRAGMA secure_delete = ON;", - "PRAGMA busy_timeout = 5000;", - } - db, err := driver.Open(dbPath, func(c *sqlite3.Conn) error { - for _, pragma := range pragmas { - if err := c.Exec(pragma); err != nil { - return fmt.Errorf("failed to set pragma %q: %w", pragma, err) + // Set pragmas for better performance via _pragma query params. + // Format: PRAGMA name = value; + for name, value := range pragmas { + if err := c.Exec(fmt.Sprintf("PRAGMA %s = %s;", name, value)); err != nil { + return fmt.Errorf("failed to set pragma %q: %w", name, err) } } return nil @@ -34,5 +25,6 @@ func openDB(dbPath string) (*sql.DB, error) { if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } + return db, nil } From d6643a64aca64744d4156126835d8bbfccce2f73 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 10 Feb 2026 15:47:23 -0300 Subject: [PATCH 099/125] fix(lsp): files outside cwd (#2180) closes #1401 Signed-off-by: Carlos Alexandro Becker --- internal/lsp/client.go | 65 +++++++++---------------------------- internal/lsp/client_test.go | 2 +- internal/lsp/manager.go | 21 +++++++++--- 3 files changed, 32 insertions(+), 56 deletions(-) diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 0fb73577f62c138abf435a789996a86dbc328993..d8a97a429ea2f3a60e600731c6343a52c51e992b 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -8,13 +8,13 @@ import ( "maps" "os" "path/filepath" - "strings" "sync" "sync/atomic" "time" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/home" powernap "github.com/charmbracelet/x/powernap/pkg/lsp" "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" @@ -35,7 +35,7 @@ type Client struct { debug bool // Working directory this LSP is scoped to. - workDir string + cwd string // File types this LSP server handles (e.g., .go, .rs, .py) fileTypes []string @@ -66,7 +66,14 @@ type Client struct { } // New creates a new LSP client using the powernap implementation. -func New(ctx context.Context, name string, cfg config.LSPConfig, resolver config.VariableResolver, debug bool) (*Client, error) { +func New( + ctx context.Context, + name string, + cfg config.LSPConfig, + resolver config.VariableResolver, + cwd string, + debug bool, +) (*Client, error) { client := &Client{ name: name, fileTypes: cfg.FileTypes, @@ -76,6 +83,7 @@ func New(ctx context.Context, name string, cfg config.LSPConfig, resolver config ctx: ctx, debug: debug, resolver: resolver, + cwd: cwd, } client.serverState.Store(StateStarting) @@ -134,13 +142,7 @@ func (c *Client) Close(ctx context.Context) error { // createPowernapClient creates a new powernap client with the current configuration. func (c *Client) createPowernapClient() error { - workDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - rootURI := string(protocol.URIFromPath(workDir)) - c.workDir = workDir + rootURI := string(protocol.URIFromPath(c.cwd)) command, err := c.resolver.ResolveValue(c.config.Command) if err != nil { @@ -157,7 +159,7 @@ func (c *Client) createPowernapClient() error { WorkspaceFolders: []protocol.WorkspaceFolder{ { URI: rootURI, - Name: filepath.Base(workDir), + Name: filepath.Base(c.cwd), }, }, } @@ -321,15 +323,8 @@ type OpenFileInfo struct { // 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 { - // 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) + if !fsext.HasPrefix(path, c.cwd) { + slog.Debug("File outside workspace", "name", c.name, "file", path, "workDir", c.cwd) return false } return handlesFiletype(c.name, c.fileTypes, path) @@ -472,31 +467,6 @@ func (c *Client) OpenFileOnDemand(ctx context.Context, filepath string) error { return c.OpenFile(ctx, filepath) } -// GetDiagnosticsForFile ensures a file is open and returns its diagnostics. -func (c *Client) GetDiagnosticsForFile(ctx context.Context, filepath string) ([]protocol.Diagnostic, error) { - documentURI := protocol.URIFromPath(filepath) - - // Make sure the file is open - if !c.IsFileOpen(filepath) { - if err := c.OpenFile(ctx, filepath); err != nil { - return nil, fmt.Errorf("failed to open file for diagnostics: %w", err) - } - - // Give the LSP server a moment to process the file - time.Sleep(100 * time.Millisecond) - } - - // Get diagnostics - diagnostics, _ := c.diagnostics.Get(documentURI) - - return diagnostics, nil -} - -// ClearDiagnosticsForURI removes diagnostics for a specific URI from the cache. -func (c *Client) ClearDiagnosticsForURI(uri protocol.DocumentURI) { - c.diagnostics.Del(uri) -} - // RegisterNotificationHandler registers a notification handler. func (c *Client) RegisterNotificationHandler(method string, handler transport.NotificationHandler) { c.client.RegisterNotificationHandler(method, handler) @@ -507,11 +477,6 @@ func (c *Client) RegisterServerRequestHandler(method string, handler transport.H c.client.RegisterHandler(method, handler) } -// DidChangeWatchedFiles sends a workspace/didChangeWatchedFiles notification to the server. -func (c *Client) DidChangeWatchedFiles(ctx context.Context, params protocol.DidChangeWatchedFilesParams) error { - return c.client.NotifyDidChangeWatchedFiles(ctx, params.Changes) -} - // openKeyConfigFiles opens important configuration files that help initialize the server. func (c *Client) openKeyConfigFiles(ctx context.Context) { wd, err := os.Getwd() diff --git a/internal/lsp/client_test.go b/internal/lsp/client_test.go index 1de51997f973909a616dc9b07283622b7839a3cb..e444354800b6e41ad26a1d8f0d8ffb097a1f060a 100644 --- a/internal/lsp/client_test.go +++ b/internal/lsp/client_test.go @@ -23,7 +23,7 @@ func TestClient(t *testing.T) { // but we can still test the basic structure client, err := New(ctx, "test", cfg, config.NewEnvironmentVariableResolver(env.NewFromMap(map[string]string{ "THE_CMD": "echo", - })), false) + })), ".", false) if err != nil { // Expected to fail with echo command, skip the rest t.Skipf("Powernap client creation failed as expected with dummy command: %v", err) diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index fae462557df045d0f4822acb3e770d6057091f6c..88f9d72972350106c9ffc52d85434b0f20ec33aa 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -65,8 +65,8 @@ func NewManager(cfg *config.Config) *Manager { } // Clients returns the map of LSP clients. -func (m *Manager) Clients() *csync.Map[string, *Client] { - return m.clients +func (s *Manager) Clients() *csync.Map[string, *Client] { + return s.clients } // SetCallback sets a callback that is invoked when a new LSP @@ -79,13 +79,17 @@ func (s *Manager) SetCallback(cb func(name string, client *Client)) { // Start starts an LSP server that can handle the given file path. // If an appropriate LSP is already running, this is a no-op. -func (s *Manager) Start(ctx context.Context, filePath string) { +func (s *Manager) Start(ctx context.Context, path string) { + if !fsext.HasPrefix(path, s.cfg.WorkingDir()) { + return + } + s.mu.Lock() defer s.mu.Unlock() var wg sync.WaitGroup for name, server := range s.manager.GetServers() { - if !handles(server, filePath, s.cfg.WorkingDir()) { + if !handles(server, path, s.cfg.WorkingDir()) { continue } wg.Go(func() { @@ -150,7 +154,14 @@ func (s *Manager) startServer(ctx context.Context, name string, server *powernap return } } - client, err := New(ctx, name, cfg, s.cfg.Resolver(), s.cfg.Options.DebugLSP) + client, err := New( + ctx, + name, + cfg, + s.cfg.Resolver(), + s.cfg.WorkingDir(), + s.cfg.Options.DebugLSP, + ) if err != nil { slog.Error("Failed to create LSP client", "name", name, "error", err) return From 22ed1e5d39ab871ae30219631c213dba6906556d Mon Sep 17 00:00:00 2001 From: M1xA Date: Tue, 10 Feb 2026 20:50:45 +0200 Subject: [PATCH 100/125] fix: clear regex cache on new session to prevent unbounded growth (#2161) --- internal/agent/tools/grep.go | 11 +++++++++++ internal/ui/model/ui.go | 2 ++ 2 files changed, 13 insertions(+) diff --git a/internal/agent/tools/grep.go b/internal/agent/tools/grep.go index 3111a3f56872f72bb1735f314637d5d530a4447b..8dc7598911a1ba368ba1b667648f0801508bba4f 100644 --- a/internal/agent/tools/grep.go +++ b/internal/agent/tools/grep.go @@ -64,6 +64,17 @@ func (rc *regexCache) get(pattern string) (*regexp.Regexp, error) { return regex, nil } +// ResetCache clears compiled regex caches to prevent unbounded growth across sessions. +func ResetCache() { + searchRegexCache.mu.Lock() + clear(searchRegexCache.cache) + searchRegexCache.mu.Unlock() + + globRegexCache.mu.Lock() + clear(globRegexCache.cache) + globRegexCache.mu.Unlock() +} + // Global regex cache instances var ( searchRegexCache = newRegexCache() diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 1088212a91ef0810bce7c5316a1bb33cad204b46..32469b697508f28b6367d91190a5af9735c1c236 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -24,6 +24,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/catwalk/pkg/catwalk" "charm.land/lipgloss/v2" + agenttools "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/commands" @@ -2986,6 +2987,7 @@ func (m *UI) newSession() tea.Cmd { m.promptQueue = 0 m.pillsView = "" m.historyReset() + agenttools.ResetCache() return tea.Batch( func() tea.Msg { m.com.App.LSPManager.StopAll(context.Background()) From 47ca98d0e29b0da47a6084bb0eaef132e1608369 Mon Sep 17 00:00:00 2001 From: M1xA Date: Tue, 10 Feb 2026 21:51:41 +0200 Subject: [PATCH 101/125] fix(config): correct Task agent ID in SetupAgents (#2101) --- internal/config/agent_id_test.go | 29 +++++++++++++++++++++++++++++ internal/config/config.go | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 internal/config/agent_id_test.go diff --git a/internal/config/agent_id_test.go b/internal/config/agent_id_test.go new file mode 100644 index 0000000000000000000000000000000000000000..74bad7f563dd1aa4c5f535f43c2204aacb1930b0 --- /dev/null +++ b/internal/config/agent_id_test.go @@ -0,0 +1,29 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfig_AgentIDs(t *testing.T) { + cfg := &Config{ + Options: &Options{ + DisabledTools: []string{}, + }, + } + cfg.SetupAgents() + + t.Run("Coder agent should have correct ID", func(t *testing.T) { + coderAgent, ok := cfg.Agents[AgentCoder] + require.True(t, ok) + assert.Equal(t, AgentCoder, coderAgent.ID, "Coder agent ID should be '%s'", AgentCoder) + }) + + t.Run("Task agent should have correct ID", func(t *testing.T) { + taskAgent, ok := cfg.Agents[AgentTask] + require.True(t, ok) + assert.Equal(t, AgentTask, taskAgent.ID, "Task agent ID should be '%s'", AgentTask) + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index 1d0aba7522948263cfac8aa3ef185749cfcc425f..079c5fb4fbf7bd9814ac83a360af98c7dc404397 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -755,7 +755,7 @@ func (c *Config) SetupAgents() { }, AgentTask: { - ID: AgentCoder, + ID: AgentTask, Name: "Task", Description: "An agent that helps with searching for context and finding implementation details.", Model: SelectedModelTypeLarge, From d1032ba98b7e20300f1fa75b4e8e33d601ec19d4 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 10 Feb 2026 20:25:30 -0300 Subject: [PATCH 102/125] fix: respect disable_default_providers (#2177) * fix: respect disable_default_providers closes #1949 Signed-off-by: Carlos Alexandro Becker * fix: use x/slice Signed-off-by: Carlos Alexandro Becker * fix: fail if no providers Signed-off-by: Carlos Alexandro Becker * test: fix Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- go.mod | 2 +- go.sum | 4 ++-- internal/config/load.go | 5 +++++ internal/config/load_test.go | 6 +++--- internal/ui/dialog/models.go | 37 +++++++++--------------------------- 5 files changed, 20 insertions(+), 34 deletions(-) diff --git a/go.mod b/go.mod index 3c5d732389a9221ceb940873a659b497b6c695ec..b26254dfe659d962398c1e0704f8259faf19e89e 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f 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/slice v0.0.0-20260209194814-eeb2896ac759 github.com/charmbracelet/x/exp/strings v0.1.0 github.com/charmbracelet/x/powernap v0.0.0-20260209132835-6b065b8ba62c github.com/charmbracelet/x/term v0.2.2 diff --git a/go.sum b/go.sum index 6f9b10920535d8ad22a3c0786d2a3a3b5b68dd21..d7c510862fa0e34657869c99e853c371ccc9b6b4 100644 --- a/go.sum +++ b/go.sum @@ -116,8 +116,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6g github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= -github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/JS+qnRcO/++xjYEDtW7x+P5E4+4cBiOHTt2Xfk= -github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/exp/slice v0.0.0-20260209194814-eeb2896ac759 h1:96wFGlst+IDv3dIf5q29nw470wJYB3YAgemiciLZcG0= +github.com/charmbracelet/x/exp/slice v0.0.0-20260209194814-eeb2896ac759/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= 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= diff --git a/internal/config/load.go b/internal/config/load.go index 0815f86d0faa4b94476c6c57670371b3eb5632f6..7753a50e25a4f6ed419feb1355a99c040a43d9e0 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -331,6 +331,11 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know c.Providers.Set(id, providerConfig) } + + if c.Providers.Len() == 0 && c.Options.DisableDefaultProviders { + return fmt.Errorf("default providers are disabled and there are no custom providers are configured") + } + return nil } diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 7229ac51d4a4c0616f268ea2a592cb12e1b818bb..93d2245193463e2a6539e23aeb0e16ac14c0ccef 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -1127,7 +1127,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) { }) resolver := NewEnvironmentVariableResolver(env) err := cfg.configureProviders(env, resolver, knownProviders) - require.NoError(t, err) + require.ErrorContains(t, err, "no custom providers") // openai should NOT be present because it lacks base_url and models. require.Equal(t, 0, cfg.Providers.Len()) @@ -1252,7 +1252,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) - require.NoError(t, err) + require.ErrorContains(t, err, "no custom providers") // Provider should be rejected for missing models. require.Equal(t, 0, cfg.Providers.Len()) @@ -1276,7 +1276,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) - require.NoError(t, err) + require.ErrorContains(t, err, "no custom providers") // Provider should be rejected for missing base_url. require.Equal(t, 0, cfg.Providers.Len()) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index d77d8952fc1d9a413d35cc3a4c3ae315e0881c60..7594c2476218ae241c4b21cbb455b19f00923c47 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -4,7 +4,6 @@ import ( "cmp" "fmt" "slices" - "strings" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" @@ -15,6 +14,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/util" uv "github.com/charmbracelet/ultraviolet" + xslice "github.com/charmbracelet/x/exp/slice" ) // ModelType represents the type of model to select. @@ -143,12 +143,14 @@ func NewModels(com *common.Common, isOnboarding bool) (*Models, error) { ) m.keyMap.Close = CloseKey - providers, err := getFilteredProviders(com.Config()) - if err != nil { - return nil, fmt.Errorf("failed to get providers: %w", err) - } - - m.providers = providers + m.providers = slices.Collect( + xslice.Map( + com.Config().Providers.Seq(), + func(pc config.ProviderConfig) catwalk.Provider { + return pc.ToProvider() + }, + ), + ) if err := m.setProviderItems(); err != nil { return nil, fmt.Errorf("failed to set provider items: %w", err) } @@ -521,27 +523,6 @@ func (m *Models) setProviderItems() error { return nil } -func getFilteredProviders(cfg *config.Config) ([]catwalk.Provider, error) { - providers, err := config.Providers(cfg) - if err != nil { - return nil, fmt.Errorf("failed to get providers: %w", err) - } - var filteredProviders []catwalk.Provider - for _, p := range providers { - var ( - isAzure = p.ID == catwalk.InferenceProviderAzure - isCopilot = p.ID == catwalk.InferenceProviderCopilot - isHyper = string(p.ID) == "hyper" - hasAPIKeyEnv = strings.HasPrefix(p.APIKey, "$") - _, isConfigured = cfg.Providers.Get(string(p.ID)) - ) - if isAzure || isCopilot || isHyper || hasAPIKeyEnv || isConfigured { - filteredProviders = append(filteredProviders, p) - } - } - return filteredProviders, nil -} - func modelKey(providerID, modelID string) string { if providerID == "" || modelID == "" { return "" From 9917b226e39863c3c053ea96ee64453cdfa9092c Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 11 Feb 2026 13:32:50 +0300 Subject: [PATCH 103/125] fix(ui): adjust sessions dialog size Use a more reasonable size for the sessions dialog. --- internal/ui/dialog/commands.go | 8 +++----- internal/ui/dialog/dialog.go | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 0b0185b03a3c992ce55ff9164ceba6115260c174..a560ce21c0a8985ddd245dffbb6045ec08350212 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -27,9 +27,7 @@ type CommandType uint func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] } const ( - sidebarCompactModeBreakpoint = 120 - defaultCommandsDialogMaxHeight = 20 - defaultCommandsDialogMaxWidth = 70 + sidebarCompactModeBreakpoint = 120 ) const ( @@ -240,8 +238,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()-t.Dialog.View.GetHorizontalBorderSize())) - height := max(0, min(defaultCommandsDialogMaxHeight, area.Dy()-t.Dialog.View.GetVerticalBorderSize())) + width := max(0, min(defaultDialogMaxWidth, area.Dx()-t.Dialog.View.GetHorizontalBorderSize())) + height := max(0, min(defaultDialogHeight, 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/dialog.go b/internal/ui/dialog/dialog.go index 990b4ed68174bee20d627dec5f7176d9466b77d8..15e0fa52f5ab7b181e5659e7526213d254f01931 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -11,9 +11,9 @@ import ( // Dialog sizing constants. const ( // defaultDialogMaxWidth is the maximum width for standard dialogs. - defaultDialogMaxWidth = 120 + defaultDialogMaxWidth = 70 // defaultDialogHeight is the default height for standard dialogs. - defaultDialogHeight = 30 + defaultDialogHeight = 20 // titleContentHeight is the height of the title content line. titleContentHeight = 1 // inputContentHeight is the height of the input content line. From 5c29ad2fd5625c83f9d9c4cdedf8133961e119df Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 11 Feb 2026 13:35:22 +0300 Subject: [PATCH 104/125] docs(ui): comment typo --- 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 2532e8c19a75ef061266afd42d688016ea0ab3c9..53ddaa3c1713d100b62ef5df25c3878663759e52 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -154,7 +154,7 @@ func renderItem(t ListItemStyles, title string, info string, focused bool, width // because we can control the underline start and stop more // precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline] // which only affect the underline attribute without interfering - // with other style + // with other style attributes. parts = append(parts, ansi.NewStyle().Underline(true).String(), ansi.Cut(title, start, stop+1), From 4f3b056c91840b2be251ff58efa0ffb02bcff56f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 11 Feb 2026 13:36:29 +0300 Subject: [PATCH 105/125] fix(ui): truncate dialog titles with ellipsis --- 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 53ddaa3c1713d100b62ef5df25c3878663759e52..66c3be7af3da3dbae88b8a4971463bfcea40eba6 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -137,7 +137,7 @@ func renderItem(t ListItemStyles, title string, info string, focused bool, width infoWidth = lipgloss.Width(infoText) } - title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "") + title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "…") titleWidth := lipgloss.Width(title) gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth)) content := title From 155fc618b392eb0f9e49daeb8ad233c943e0349c Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 11 Feb 2026 13:48:22 +0300 Subject: [PATCH 106/125] fix(ui): dialogs: loop around and scroll list when navigating with up/down keys --- internal/ui/dialog/commands.go | 10 ++++------ internal/ui/dialog/models.go | 10 ++++------ internal/ui/dialog/sessions.go | 10 ++++------ 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index a560ce21c0a8985ddd245dffbb6045ec08350212..6e769e66f7217c994f877582e8ca2eca80577b9a 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -153,19 +153,17 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action { c.list.Focus() if c.list.IsSelectedFirst() { c.list.SelectLast() - c.list.ScrollToBottom() - break + } else { + c.list.SelectPrev() } - c.list.SelectPrev() c.list.ScrollToSelected() case key.Matches(msg, c.keyMap.Next): c.list.Focus() if c.list.IsSelectedLast() { c.list.SelectFirst() - c.list.ScrollToTop() - break + } else { + c.list.SelectNext() } - c.list.SelectNext() c.list.ScrollToSelected() case key.Matches(msg, c.keyMap.Select): if selectedItem := c.list.SelectedItem(); selectedItem != nil { diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 7594c2476218ae241c4b21cbb455b19f00923c47..657cdd362de044defdd84928cb01fad4477b37b0 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -174,19 +174,17 @@ func (m *Models) HandleMsg(msg tea.Msg) Action { m.list.Focus() if m.list.IsSelectedFirst() { m.list.SelectLast() - m.list.ScrollToBottom() - break + } else { + m.list.SelectPrev() } - m.list.SelectPrev() m.list.ScrollToSelected() case key.Matches(msg, m.keyMap.Next): m.list.Focus() if m.list.IsSelectedLast() { m.list.SelectFirst() - m.list.ScrollToTop() - break + } else { + m.list.SelectNext() } - m.list.SelectNext() m.list.ScrollToSelected() case key.Matches(msg, m.keyMap.Select, m.keyMap.Edit): selectedItem := m.list.SelectedItem() diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index cfb0f30623c383b775c3a960134057e6c79ce9b8..7f9bb952617e86708bcd1bf5d02c007029e3e53e 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -190,19 +190,17 @@ func (s *Session) HandleMsg(msg tea.Msg) Action { s.list.Focus() if s.list.IsSelectedFirst() { s.list.SelectLast() - s.list.ScrollToBottom() - break + } else { + s.list.SelectPrev() } - s.list.SelectPrev() s.list.ScrollToSelected() case key.Matches(msg, s.keyMap.Next): s.list.Focus() if s.list.IsSelectedLast() { s.list.SelectFirst() - s.list.ScrollToTop() - break + } else { + s.list.SelectNext() } - s.list.SelectNext() s.list.ScrollToSelected() case key.Matches(msg, s.keyMap.Select): if item := s.list.SelectedItem(); item != nil { From 0ed58061f8fe5252063b4f413562a3a6ea9b36cf Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 11 Feb 2026 09:03:08 -0300 Subject: [PATCH 107/125] ci: golangci-lint 2.9 (#2193) Signed-off-by: Carlos Alexandro Becker --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b06477697f90cc18f0aee221954c5c38e7ba167a..d71849463f72b5e286aff46c42260d30322b06a1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,5 +8,5 @@ jobs: uses: charmbracelet/meta/.github/workflows/lint.yml@main with: golangci_path: .golangci.yml - golangci_version: v2.4 + golangci_version: v2.9 timeout: 10m From 7f3d7c756d4bc289c45ccb23e2a8a9c1c2e21b0b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 11 Feb 2026 09:03:28 -0300 Subject: [PATCH 108/125] refactor: use csync.Map for regex caches (#2187) * refactor: use csync.Map for regex caches Signed-off-by: Carlos Alexandro Becker * fix: lint Signed-off-by: Carlos Alexandro Becker * ci: golangci-lint 2.9 Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- internal/agent/tools/grep.go | 49 ++++++++++-------------------------- 1 file changed, 13 insertions(+), 36 deletions(-) diff --git a/internal/agent/tools/grep.go b/internal/agent/tools/grep.go index 8dc7598911a1ba368ba1b667648f0801508bba4f..5059396ace28828e61d7beab2705f700d5f8b50f 100644 --- a/internal/agent/tools/grep.go +++ b/internal/agent/tools/grep.go @@ -15,64 +15,41 @@ import ( "regexp" "sort" "strings" - "sync" "time" "charm.land/fantasy" + "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/fsext" ) // regexCache provides thread-safe caching of compiled regex patterns type regexCache struct { - cache map[string]*regexp.Regexp - mu sync.RWMutex + *csync.Map[string, *regexp.Regexp] } // newRegexCache creates a new regex cache func newRegexCache() *regexCache { return ®exCache{ - cache: make(map[string]*regexp.Regexp), + Map: csync.NewMap[string, *regexp.Regexp](), } } // get retrieves a compiled regex from cache or compiles and caches it func (rc *regexCache) get(pattern string) (*regexp.Regexp, error) { - // Try to get from cache first (read lock) - rc.mu.RLock() - if regex, exists := rc.cache[pattern]; exists { - rc.mu.RUnlock() - return regex, nil - } - rc.mu.RUnlock() - - // Compile the regex (write lock) - rc.mu.Lock() - defer rc.mu.Unlock() - - // Double-check in case another goroutine compiled it while we waited - if regex, exists := rc.cache[pattern]; exists { - return regex, nil - } - - // Compile and cache the regex - regex, err := regexp.Compile(pattern) - if err != nil { - return nil, err - } - - rc.cache[pattern] = regex - return regex, nil + var rerr error + return rc.GetOrSet(pattern, func() *regexp.Regexp { + regex, err := regexp.Compile(pattern) + if err != nil { + rerr = err + } + return regex + }), rerr } // ResetCache clears compiled regex caches to prevent unbounded growth across sessions. func ResetCache() { - searchRegexCache.mu.Lock() - clear(searchRegexCache.cache) - searchRegexCache.mu.Unlock() - - globRegexCache.mu.Lock() - clear(globRegexCache.cache) - globRegexCache.mu.Unlock() + searchRegexCache.Reset(map[string]*regexp.Regexp{}) + globRegexCache.Reset(map[string]*regexp.Regexp{}) } // Global regex cache instances From 8eb4dc0578b528901da779d6a34b986e9f6281cc Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 11 Feb 2026 11:06:05 -0300 Subject: [PATCH 109/125] chore(taskfile): add `-v` to `go build` --- Taskfile.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 2d5462a63ea12468bbaa574454038eb105146867..e20177a86873cba8b685675032da6b1f65282e1b 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -45,7 +45,7 @@ tasks: vars: LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}' cmds: - - "go build {{if .RACE}}-race{{end}} {{.LDFLAGS}} ." + - "go build -v {{if .RACE}}-race{{end}} {{.LDFLAGS}} ." sources: - ./**/*.go - go.mod From 361a318e2240239b175f24584265a31befc8538f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 11 Feb 2026 17:13:14 +0300 Subject: [PATCH 110/125] fix(ui): ensure the min size accounts for the dialog border --- internal/ui/dialog/sessions.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 7f9bb952617e86708bcd1bf5d02c007029e3e53e..6f9b7724a796818c789e19ba9455c23e7e51c9b4 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -229,9 +229,9 @@ func (s *Session) Cursor() *tea.Cursor { // Draw implements [Dialog]. func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := s.com.Styles - width := max(0, min(defaultDialogMaxWidth, area.Dx())) - height := max(0, min(defaultDialogHeight, area.Dy())) - innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2 + width := max(0, min(defaultDialogMaxWidth, 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 + t.Dialog.HelpView.GetVerticalFrameSize() + From f87d95b8522e5def84ce3dc749411dad1a7835a9 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 11 Feb 2026 17:15:46 +0300 Subject: [PATCH 111/125] fix(ui): correctly position cursor when attachments are present (#2190) This change fixes a UI issue where the cursor is off by one line when attachments are present in the editor. --- internal/ui/model/ui.go | 34 +++++++++++++--------------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 32469b697508f28b6367d91190a5af9735c1c236..b8d94a12bb57c03cdb814a2346b117a01acfdd92 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1928,12 +1928,8 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { if m.textarea.Focused() { cur := m.textarea.Cursor() - cur.X++ // Adjust for app margins - cur.Y += m.layout.editor.Min.Y - // Offset for attachment row if present. - if len(m.attachments.List()) > 0 { - cur.Y++ - } + cur.X++ // Adjust for app margins + cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row return cur } } @@ -2223,7 +2219,10 @@ func (m *UI) updateSize() { m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy()) m.textarea.SetWidth(m.layout.editor.Dx()) - m.textarea.SetHeight(m.layout.editor.Dy()) + // TODO: Abstract the textarea and attachments into a single editor + // component so we don't have to manually account for the attachments + // height here. + m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin m.renderPills() // Handle different app states @@ -2375,14 +2374,6 @@ func (m *UI) generateLayout(w, h int) uiLayout { } } - if !uiLayout.editor.Empty() { - // Add editor margins 1 top and bottom - if len(m.attachments.List()) == 0 { - uiLayout.editor.Min.Y += 1 - } - uiLayout.editor.Max.Y -= 1 - } - return uiLayout } @@ -2686,14 +2677,15 @@ func (m *UI) randomizePlaceholders() { // renderEditorView renders the editor view with attachments if any. func (m *UI) renderEditorView(width int) string { - if len(m.attachments.List()) == 0 { - return m.textarea.View() + var attachmentsView string + if len(m.attachments.List()) > 0 { + attachmentsView = m.attachments.Render(width) } - return lipgloss.JoinVertical( - lipgloss.Top, - m.attachments.Render(width), + return strings.Join([]string{ + attachmentsView, m.textarea.View(), - ) + "", // margin at bottom of editor + }, "\n") } // cacheSidebarLogo renders and caches the sidebar logo at the specified width. From 8a6f25f945785322cfcbb78772a3819610861a59 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 11 Feb 2026 11:33:00 -0300 Subject: [PATCH 112/125] chore(taskfile): run binary with extension on windows --- Taskfile.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index e20177a86873cba8b685675032da6b1f65282e1b..476626fde4f0ed33d26fa20c2dc8b00ecd557af6 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -56,7 +56,7 @@ tasks: desc: Run build cmds: - task: build - - "./crush {{.CLI_ARGS}} {{if .RACE}}2>race.log{{end}}" + - "./crush{{exeExt}} {{.CLI_ARGS}} {{if .RACE}}2>race.log{{end}}" run:catwalk: desc: Run build with local Catwalk @@ -64,7 +64,7 @@ tasks: CATWALK_URL: http://localhost:8080 cmds: - task: build - - ./crush {{.CLI_ARGS}} + - ./crush{{exeExt}} {{.CLI_ARGS}} run:onboarding: desc: Run build with custom config to test onboarding @@ -74,7 +74,7 @@ tasks: cmds: - task: build - rm -rf tmp/onboarding - - ./crush {{.CLI_ARGS}} + - ./crush{exeExt} {{.CLI_ARGS}} test: desc: Run tests From 9940e832fe36dcc7958fb96bf0d695270bb36e11 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 11 Feb 2026 15:18:15 -0300 Subject: [PATCH 113/125] chore(deps): update catwalk to v0.18.0 (#2198) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b26254dfe659d962398c1e0704f8259faf19e89e..4ae4741b1afa26548d6ea2c12abaa760249a58e8 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +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.20260209074636-30878e43d7b0 - charm.land/catwalk v0.17.1 + charm.land/catwalk v0.18.0 charm.land/fantasy v0.7.2 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 diff --git a/go.sum b/go.sum index d7c510862fa0e34657869c99e853c371ccc9b6b4..dd08812c12b5b6af028b2375fd340800c152ac5b 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +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.20260209074636-30878e43d7b0 h1:HAbpM9TPjZM18D677ww3VnkKXdd2hyMQtHUsVV0HcPQ= charm.land/bubbletea/v2 v2.0.0-rc.2.0.20260209074636-30878e43d7b0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= -charm.land/catwalk v0.17.1 h1:UsHvBi3S7CxONiIZTWKTXM+H9qla8I0fCb/SVru33ms= -charm.land/catwalk v0.17.1/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64= +charm.land/catwalk v0.18.0 h1:vBbhhxuGqkx2qVzom54ElJyBCQHn30dOnPYG977za4Q= +charm.land/catwalk v0.18.0/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64= charm.land/fantasy v0.7.2 h1:OUBgbs7hllZE7rpJP9SzdsGE/hMCm+mr11iEIqU02hE= charm.land/fantasy v0.7.2/go.mod h1:vH6F5eYqaxgNEvDQdXRsOsfvoRyT3f/uJngPNJmcDmw= charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0= From 5b2a0bf38e8dd67707e3d433e59de5740aedfc97 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 11 Feb 2026 16:10:36 -0300 Subject: [PATCH 114/125] fix(grep): do not go outside cwd, add timeout (#2188) * fix(grep): do not go outside cwd, add timeout Signed-off-by: Carlos Alexandro Becker * fix: timeout config Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- internal/agent/agentic_fetch_tool.go | 2 +- internal/agent/common_test.go | 2 +- internal/agent/coordinator.go | 2 +- internal/agent/tools/grep.go | 9 ++++++--- internal/config/config.go | 13 ++++++++++++- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 9bb27327516da66323fee454b9048cf5f9f69b6b..2d52814d446581fca0e7a98368ffaae465aedf2c 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -167,7 +167,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( webFetchTool, webSearchTool, tools.NewGlobTool(tmpDir), - tools.NewGrepTool(tmpDir), + tools.NewGrepTool(tmpDir, c.cfg.Tools.Grep), tools.NewSourcegraphTool(client), tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir), } diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index 1a420e2b40b84027db7469a71ca9212b69f6e380..3ab3e68ec046dfb8db9dd0801c4a744c7e148bd2 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -208,7 +208,7 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel tools.NewMultiEditTool(nil, 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.NewGrepTool(env.workingDir, cfg.Tools.Grep), tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls), tools.NewSourcegraphTool(r.GetDefaultClient()), tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir), diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index a6048a7620bef5236ef8266612538685dcf48aac..a1fd6ae3dd293cac5ad65079c96bad4acd47c4f9 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -426,7 +426,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan tools.NewMultiEditTool(c.lspManager, 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.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Tools.Grep), tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls), tools.NewSourcegraphTool(nil), tools.NewTodosTool(c.sessions), diff --git a/internal/agent/tools/grep.go b/internal/agent/tools/grep.go index 5059396ace28828e61d7beab2705f700d5f8b50f..8c7ec51e4e3768e4814b2d6baa3c7085357f5b45 100644 --- a/internal/agent/tools/grep.go +++ b/internal/agent/tools/grep.go @@ -18,6 +18,7 @@ import ( "time" "charm.land/fantasy" + "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/fsext" ) @@ -100,7 +101,7 @@ func escapeRegexPattern(pattern string) string { return escaped } -func NewGrepTool(workingDir string) fantasy.AgentTool { +func NewGrepTool(workingDir string, config config.ToolGrep) fantasy.AgentTool { return fantasy.NewAgentTool( GrepToolName, string(grepDescription), @@ -109,7 +110,6 @@ func NewGrepTool(workingDir string) fantasy.AgentTool { return fantasy.NewTextErrorResponse("pattern is required"), nil } - // If literal_text is true, escape the pattern searchPattern := params.Pattern if params.LiteralText { searchPattern = escapeRegexPattern(params.Pattern) @@ -120,7 +120,10 @@ func NewGrepTool(workingDir string) fantasy.AgentTool { searchPath = workingDir } - matches, truncated, err := searchFiles(ctx, searchPattern, searchPath, params.Include, 100) + searchCtx, cancel := context.WithTimeout(ctx, config.GetTimeout()) + defer cancel() + + matches, truncated, err := searchFiles(searchCtx, searchPattern, searchPath, params.Include, 100) if err != nil { return fantasy.NewTextErrorResponse(fmt.Sprintf("error searching files: %v", err)), nil } diff --git a/internal/config/config.go b/internal/config/config.go index 079c5fb4fbf7bd9814ac83a360af98c7dc404397..753151509315545dfbed9bd74c1455785313c8aa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -350,7 +350,8 @@ type Agent struct { } type Tools struct { - Ls ToolLs `json:"ls,omitzero"` + Ls ToolLs `json:"ls,omitzero"` + Grep ToolGrep `json:"grep,omitzero"` } type ToolLs struct { @@ -358,10 +359,20 @@ type ToolLs struct { MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"` } +// Limits returns the user-defined max-depth and max-items, or their defaults. func (t ToolLs) Limits() (depth, items int) { return ptrValOr(t.MaxDepth, 0), ptrValOr(t.MaxItems, 0) } +type ToolGrep struct { + Timeout *time.Duration `json:"timeout,omitempty" jsonschema:"description=Timeout for the grep tool call,default=5s,example=10s"` +} + +// GetTimeout returns the user-defined timeout or the default. +func (t ToolGrep) GetTimeout() time.Duration { + return ptrValOr(t.Timeout, 5*time.Second) +} + // Config holds the configuration for crush. type Config struct { Schema string `json:"$schema,omitempty"` From f22ec5fcad7b4905ffa2d1ea31be930911291cae Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:12:18 +0000 Subject: [PATCH 115/125] chore: auto-update files --- schema.json | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/schema.json b/schema.json index f54df0996a5a763a9265bb51efb1d70c29780d63..298d8fe814b80fa693759e9de5a1dafb921b389f 100644 --- a/schema.json +++ b/schema.json @@ -690,6 +690,16 @@ "expires_at" ] }, + "ToolGrep": { + "properties": { + "timeout": { + "type": "integer", + "description": "Timeout for the grep tool call" + } + }, + "additionalProperties": false, + "type": "object" + }, "ToolLs": { "properties": { "max_depth": { @@ -716,12 +726,16 @@ "properties": { "ls": { "$ref": "#/$defs/ToolLs" + }, + "grep": { + "$ref": "#/$defs/ToolGrep" } }, "additionalProperties": false, "type": "object", "required": [ - "ls" + "ls", + "grep" ] } } From 0b618f40fb824abb8ddf5b9ea7d145150c4d501a Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 11 Feb 2026 17:19:19 -0300 Subject: [PATCH 116/125] fix: ensure all providers are shown unless `disable_default_providers` is set (#2197) --- internal/config/provider.go | 6 +++++- internal/ui/dialog/models.go | 15 ++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/internal/config/provider.go b/internal/config/provider.go index 6ca981e5a73cbf3e3472b05f55c7b911a4a857c3..bd960e0c27ca11d5ef376c221717f061ad3deb47 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -145,11 +145,15 @@ func Providers(cfg *Config) ([]catwalk.Provider, error) { var errs []error providers := csync.NewSlice[catwalk.Provider]() autoupdate := !cfg.Options.DisableProviderAutoUpdate + customProvidersOnly := cfg.Options.DisableDefaultProviders ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() wg.Go(func() { + if customProvidersOnly { + return + } catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL) client := catwalk.NewWithURL(catwalkURL) path := cachePathFor("providers") @@ -165,7 +169,7 @@ func Providers(cfg *Config) ([]catwalk.Provider, error) { }) wg.Go(func() { - if !hyper.Enabled() { + if customProvidersOnly || !hyper.Enabled() { return } path := cachePathFor("hyper") diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 657cdd362de044defdd84928cb01fad4477b37b0..937eac19cab996104a22751270839ebf0656a04d 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -14,7 +14,6 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/util" uv "github.com/charmbracelet/ultraviolet" - xslice "github.com/charmbracelet/x/exp/slice" ) // ModelType represents the type of model to select. @@ -143,14 +142,12 @@ func NewModels(com *common.Common, isOnboarding bool) (*Models, error) { ) m.keyMap.Close = CloseKey - m.providers = slices.Collect( - xslice.Map( - com.Config().Providers.Seq(), - func(pc config.ProviderConfig) catwalk.Provider { - return pc.ToProvider() - }, - ), - ) + var err error + m.providers, err = config.Providers(m.com.Config()) + if err != nil { + return nil, fmt.Errorf("failed to get providers: %w", err) + } + if err := m.setProviderItems(); err != nil { return nil, fmt.Errorf("failed to set provider items: %w", err) } From baedc28232f80b434664c1a85f957c4848c1e84c Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 11 Feb 2026 17:19:51 -0300 Subject: [PATCH 117/125] fix: address potential panic on shell command execution (#2200) This panic happen once in a while on CI on Windows specifically. I personally never saw it happening myself, but I think it's possible to happen for the end user on Windows as well. Looks like a potential bug on the interpreter, but in the meantime let's at least recover from the panic and gracefully handle it. panic: ended up with a non-nil exitStatus.err but a zero exitStatus.code goroutine 61 [running]: mvdan.cc/sh/v3/interp.(*Runner).Run(0xc000220848, {0x1415220e0, 0xc00021a1e0}, {0x14151e088, 0xc00025a600}) C:/Users/runneradmin/go/pkg/mod/mvdan.cc/sh/v3@v3.12.1-0.20250902163504-3cf4fd5717a5/interp/api.go:929 +0x6b2 github.com/charmbracelet/crush/internal/shell.(*Shell).execCommon(0xc000256360, {0x1415220e0, 0xc00021a1e0}, {0x14135a250, 0x9}, {0x14151baa0, 0xc00025a540}, {0x14151baa0, 0xc00025a580}) D:/a/crush/crush/internal/shell/shell.go:273 +0x285 github.com/charmbracelet/crush/internal/shell.(*Shell).execStream(...) D:/a/crush/crush/internal/shell/shell.go:288 github.com/charmbracelet/crush/internal/shell.(*Shell).ExecStream(0xc000256360, {0x1415220e0, 0xc00021a1e0}, {0x14135a250, 0x9}, {0x14151baa0, 0xc00025a540}, {0x14151baa0, 0xc00025a580}) D:/a/crush/crush/internal/shell/shell.go:111 +0x139 github.com/charmbracelet/crush/internal/shell.(*BackgroundShellManager).Start.func1() D:/a/crush/crush/internal/shell/background.go:122 +0x15f created by github.com/charmbracelet/crush/internal/shell.(*BackgroundShellManager).Start in goroutine 28 D:/a/crush/crush/internal/shell/background.go:119 +0x72a --- internal/shell/shell.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/shell/shell.go b/internal/shell/shell.go index d8dde82a0077d3be5cd19c2714e5a1a5097d015c..580ab3c5d592f6e2f41bbf65dfea6990d3b35b2f 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -259,20 +259,29 @@ func (s *Shell) updateShellFromRunner(runner *interp.Runner) { } // execCommon is the shared implementation for executing commands -func (s *Shell) execCommon(ctx context.Context, command string, stdout, stderr io.Writer) error { +func (s *Shell) execCommon(ctx context.Context, command string, stdout, stderr io.Writer) (err error) { + var runner *interp.Runner + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("command execution panic: %v", r) + } + if runner != nil { + s.updateShellFromRunner(runner) + } + s.logger.InfoPersist("command finished", "command", command, "err", err) + }() + line, err := syntax.NewParser().Parse(strings.NewReader(command), "") if err != nil { return fmt.Errorf("could not parse command: %w", err) } - runner, err := s.newInterp(stdout, stderr) + runner, err = s.newInterp(stdout, stderr) if err != nil { return fmt.Errorf("could not run command: %w", err) } err = runner.Run(ctx, line) - s.updateShellFromRunner(runner) - s.logger.InfoPersist("command finished", "command", command, "err", err) return err } From 5179b28efd13e48cb3419fede83958930a065682 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 11 Feb 2026 17:46:18 -0300 Subject: [PATCH 118/125] feat(lsp): show user-configured LSPs in the UI (#2192) * feat(lsp): show user-configured LSPs in the UI This will show the user-configured LSPs as stopped in the UI. Maybe we should have a different state for "waiting"? Signed-off-by: Carlos Alexandro Becker * chore(lsp): mark "unstarted" LSPs as such, use named styles * fix: add unstarted state Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker Co-authored-by: Christian Rocha --- internal/app/app.go | 5 ++++ internal/lsp/client.go | 3 ++- internal/lsp/manager.go | 18 +++++++++++++ internal/ui/model/lsp.go | 50 ++++++++++++++++++------------------ internal/ui/model/mcp.go | 26 +++++++++---------- internal/ui/styles/styles.go | 24 +++++++++++------ 6 files changed, 79 insertions(+), 47 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index ba955e311e6a22b89bbe44d64fc7f1bfb01d8850..e923a0337f125cab17192e94ef61e17d23ae6582 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -126,9 +126,14 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { // Set up callback for LSP state updates. app.LSPManager.SetCallback(func(name string, client *lsp.Client) { + if client == nil { + updateLSPState(name, lsp.StateUnstarted, nil, nil, 0) + return + } client.SetDiagnosticsCallback(updateLSPDiagnostics) updateLSPState(name, client.GetServerState(), nil, client, 0) }) + go app.LSPManager.TrackConfigured() return app, nil } diff --git a/internal/lsp/client.go b/internal/lsp/client.go index d8a97a429ea2f3a60e600731c6343a52c51e992b..c82dffabf40e99dc932e2fd326b24031d3e04ebb 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -241,10 +241,11 @@ func (c *Client) Restart() error { type ServerState int const ( - StateStopped ServerState = iota + StateUnstarted ServerState = iota StateStarting StateReady StateError + StateStopped StateDisabled ) diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index 88f9d72972350106c9ffc52d85434b0f20ec33aa..efa7596a685786e3a3c4053eb94f8858c7549e9f 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -77,6 +77,24 @@ func (s *Manager) SetCallback(cb func(name string, client *Client)) { s.callback = cb } +// TrackConfigured will callback the user-configured LSPs, but will not create +// any clients. +func (s *Manager) TrackConfigured() { + s.mu.Lock() + defer s.mu.Unlock() + + var wg sync.WaitGroup + for name := range s.manager.GetServers() { + if !s.isUserConfigured(name) { + continue + } + wg.Go(func() { + s.callback(name, nil) + }) + } + wg.Wait() +} + // Start starts an LSP server that can handle the given file path. // If an appropriate LSP is already running, this is a no-op. func (s *Manager) Start(ctx context.Context, path string) { diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go index 9566e7b28403685e4a961e01158cfbf027d5e156..87de0d39d20520b3b24f5da3861efe7d5f9fe4a5 100644 --- a/internal/ui/model/lsp.go +++ b/internal/ui/model/lsp.go @@ -31,26 +31,23 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string { var lsps []LSPInfo for _, state := range states { - client, ok := m.com.App.LSPManager.Clients().Get(state.Name) - if !ok { - continue - } - counts := client.GetDiagnosticCounts() - lspErrs := map[protocol.DiagnosticSeverity]int{ - protocol.SeverityError: counts.Error, - protocol.SeverityWarning: counts.Warning, - protocol.SeverityHint: counts.Hint, - protocol.SeverityInformation: counts.Information, + lspErrs := map[protocol.DiagnosticSeverity]int{} + if client, ok := m.com.App.LSPManager.Clients().Get(state.Name); ok { + counts := client.GetDiagnosticCounts() + lspErrs[protocol.SeverityError] = counts.Error + lspErrs[protocol.SeverityWarning] = counts.Warning + lspErrs[protocol.SeverityHint] = counts.Hint + lspErrs[protocol.SeverityInformation] = counts.Information } lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs}) } - title := t.Subtle.Render("LSPs") + title := t.ResourceGroupTitle.Render("LSPs") if isSection { title = common.Section(t, title, width) } - list := t.Subtle.Render("None") + list := t.ResourceAdditionalText.Render("None") if len(lsps) > 0 { list = lspList(t, lsps, width, maxItems) } @@ -85,30 +82,33 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string { var renderedLsps []string for _, l := range lsps { var icon string - title := l.Name + title := t.ResourceName.Render(l.Name) var description string var diagnostics string switch l.State { + case lsp.StateUnstarted: + icon = t.ResourceOfflineIcon.String() + description = t.ResourceStatus.Render("unstarted") case lsp.StateStopped: - icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() - description = t.Subtle.Render("stopped") + icon = t.ResourceOfflineIcon.String() + description = t.ResourceStatus.Render("stopped") case lsp.StateStarting: - icon = t.ItemBusyIcon.String() - description = t.Subtle.Render("starting...") + icon = t.ResourceBusyIcon.String() + description = t.ResourceStatus.Render("starting...") case lsp.StateReady: - icon = t.ItemOnlineIcon.String() + icon = t.ResourceOnlineIcon.String() diagnostics = lspDiagnostics(t, l.Diagnostics) case lsp.StateError: - icon = t.ItemErrorIcon.String() - description = t.Subtle.Render("error") + icon = t.ResourceErrorIcon.String() + description = t.ResourceStatus.Render("error") if l.Error != nil { - description = t.Subtle.Render(fmt.Sprintf("error: %s", l.Error.Error())) + description = t.ResourceStatus.Render(fmt.Sprintf("error: %s", l.Error.Error())) } case lsp.StateDisabled: - icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() - description = t.Subtle.Render("disabled") + icon = t.ResourceOfflineIcon.Foreground(t.Muted.GetBackground()).String() + description = t.ResourceStatus.Render("disabled") default: - icon = t.ItemOfflineIcon.String() + icon = t.ResourceOfflineIcon.String() } renderedLsps = append(renderedLsps, common.Status(t, common.StatusOpts{ Icon: icon, @@ -121,7 +121,7 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string { if len(renderedLsps) > maxItems { visibleItems := renderedLsps[:maxItems-1] remaining := len(renderedLsps) - maxItems - visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) + visibleItems = append(visibleItems, t.ResourceAdditionalText.Render(fmt.Sprintf("…and %d more", remaining))) return lipgloss.JoinVertical(lipgloss.Left, visibleItems...) } return lipgloss.JoinVertical(lipgloss.Left, renderedLsps...) diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go index 517016f0dcb9b5f237d4ac09c9816a290a42fdcc..c5c94268d2985fff3c79590d3f432872439962b2 100644 --- a/internal/ui/model/mcp.go +++ b/internal/ui/model/mcp.go @@ -22,11 +22,11 @@ func (m *UI) mcpInfo(width, maxItems int, isSection bool) string { } } - title := t.Subtle.Render("MCPs") + title := t.ResourceGroupTitle.Render("MCPs") if isSection { title = common.Section(t, title, width) } - list := t.Subtle.Render("None") + list := t.ResourceAdditionalText.Render("None") if len(mcps) > 0 { list = mcpList(t, mcps, width, maxItems) } @@ -59,28 +59,28 @@ func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) strin for _, m := range mcps { var icon string - title := m.Name + title := t.ResourceName.Render(m.Name) var description string var extraContent string switch m.State { case mcp.StateStarting: - icon = t.ItemBusyIcon.String() - description = t.Subtle.Render("starting...") + icon = t.ResourceBusyIcon.String() + description = t.ResourceStatus.Render("starting...") case mcp.StateConnected: - icon = t.ItemOnlineIcon.String() + icon = t.ResourceOnlineIcon.String() extraContent = mcpCounts(t, m.Counts) case mcp.StateError: - icon = t.ItemErrorIcon.String() - description = t.Subtle.Render("error") + icon = t.ResourceErrorIcon.String() + description = t.ResourceStatus.Render("error") if m.Error != nil { - description = t.Subtle.Render(fmt.Sprintf("error: %s", m.Error.Error())) + description = t.ResourceStatus.Render(fmt.Sprintf("error: %s", m.Error.Error())) } case mcp.StateDisabled: - icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() - description = t.Subtle.Render("disabled") + icon = t.ResourceOfflineIcon.Foreground(t.Muted.GetBackground()).String() + description = t.ResourceStatus.Render("disabled") default: - icon = t.ItemOfflineIcon.String() + icon = t.ResourceOfflineIcon.String() } renderedMcps = append(renderedMcps, common.Status(t, common.StatusOpts{ @@ -94,7 +94,7 @@ func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) strin if len(renderedMcps) > maxItems { visibleItems := renderedMcps[:maxItems-1] remaining := len(renderedMcps) - maxItems - visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) + visibleItems = append(visibleItems, t.ResourceAdditionalText.Render(fmt.Sprintf("…and %d more", remaining))) return lipgloss.JoinVertical(lipgloss.Left, visibleItems...) } return lipgloss.JoinVertical(lipgloss.Left, renderedMcps...) diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index d28dd1b462ffa6e7bc6bc2c1a34b4ef66d513ef7..c0a15bc69ebd103adaa3d193645915184b64361a 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -109,10 +109,14 @@ type Styles struct { TextSelection lipgloss.Style // LSP and MCP status indicators - ItemOfflineIcon lipgloss.Style - ItemBusyIcon lipgloss.Style - ItemErrorIcon lipgloss.Style - ItemOnlineIcon lipgloss.Style + ResourceGroupTitle lipgloss.Style + ResourceOfflineIcon lipgloss.Style + ResourceBusyIcon lipgloss.Style + ResourceErrorIcon lipgloss.Style + ResourceOnlineIcon lipgloss.Style + ResourceName lipgloss.Style + ResourceStatus lipgloss.Style + ResourceAdditionalText lipgloss.Style // Markdown & Chroma Markdown ansi.StyleConfig @@ -1199,10 +1203,14 @@ func DefaultStyles() Styles { s.Initialize.Accent = s.Base.Foreground(greenDark) // LSP and MCP status. - s.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●") - s.ItemBusyIcon = s.ItemOfflineIcon.Foreground(charmtone.Citron) - s.ItemErrorIcon = s.ItemOfflineIcon.Foreground(charmtone.Coral) - s.ItemOnlineIcon = s.ItemOfflineIcon.Foreground(charmtone.Guac) + s.ResourceGroupTitle = lipgloss.NewStyle().Foreground(charmtone.Oyster) + s.ResourceOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Iron).SetString("●") + s.ResourceBusyIcon = s.ResourceOfflineIcon.Foreground(charmtone.Citron) + s.ResourceErrorIcon = s.ResourceOfflineIcon.Foreground(charmtone.Coral) + s.ResourceOnlineIcon = s.ResourceOfflineIcon.Foreground(charmtone.Guac) + s.ResourceName = lipgloss.NewStyle().Foreground(charmtone.Squid) + s.ResourceStatus = lipgloss.NewStyle().Foreground(charmtone.Oyster) + s.ResourceAdditionalText = lipgloss.NewStyle().Foreground(charmtone.Oyster) // LSP s.LSP.ErrorDiagnostic = s.Base.Foreground(redDark) From f791e5c4d9540c2faf65fb25158db9800c3b874c Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 11 Feb 2026 18:00:12 -0300 Subject: [PATCH 119/125] fix: change binding to open/close todo list from `ctrl+space` to `ctrl+t` (#2201) `ctrl+space` is used by some terminal emulators and operating system, so some users were enable to use it. `ctrl+t` should work everywhere. Fixes #1618 --- internal/ui/model/keys.go | 4 ++-- internal/ui/model/pills.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index 2018c0b644c7d68092c7f4bf990f0bb5c119c28e..f623a122a4aecf638c98a231d416781399ffd1a7 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -173,8 +173,8 @@ func DefaultKeyMap() KeyMap { key.WithHelp("ctrl+d", "toggle details"), ) km.Chat.TogglePills = key.NewBinding( - key.WithKeys("ctrl+space"), - key.WithHelp("ctrl+space", "toggle tasks"), + key.WithKeys("ctrl+t", "ctrl+space"), + key.WithHelp("ctrl+t", "toggle tasks"), ) km.Chat.PillLeft = key.NewBinding( key.WithKeys("left"), diff --git a/internal/ui/model/pills.go b/internal/ui/model/pills.go index 9199bc6deece64774343087bc596396b54272f4c..6dc87fabd3f14733f3da7e56eb1382df0d825b94 100644 --- a/internal/ui/model/pills.go +++ b/internal/ui/model/pills.go @@ -270,7 +270,7 @@ func (m *UI) renderPills() { if m.pillsExpanded { helpDesc = "close" } - helpKey := t.Pills.HelpKey.Render("ctrl+space") + helpKey := t.Pills.HelpKey.Render("ctrl+t") helpText := t.Pills.HelpText.Render(helpDesc) helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText) pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint) From 976cca116c531a915e7ad301f6a7e4a728c8d62e Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 12 Feb 2026 13:06:42 +0300 Subject: [PATCH 121/125] chore: upgrade lipgloss and colorprofile to fix windows terminal bash detection --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 4ae4741b1afa26548d6ea2c12abaa760249a58e8..2d5e7c78db09abe77f7aef404c051e57e24043cd 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/catwalk v0.18.0 charm.land/fantasy v0.7.2 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b - charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da charm.land/x/vcr v0.1.1 github.com/JohannesKaufmann/html-to-markdown v1.6.0 @@ -20,7 +20,7 @@ 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/colorprofile v0.4.1 + github.com/charmbracelet/colorprofile v0.4.2 github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 github.com/charmbracelet/x/ansi v0.11.6 @@ -175,7 +175,7 @@ require ( golang.org/x/image v0.34.0 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.39.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.239.0 // indirect diff --git a/go.sum b/go.sum index dd08812c12b5b6af028b2375fd340800c152ac5b..8469b97cf1ce51255d4194799f9231bfaf74fa59 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ charm.land/fantasy v0.7.2 h1:OUBgbs7hllZE7rpJP9SzdsGE/hMCm+mr11iEIqU02hE= charm.land/fantasy v0.7.2/go.mod h1:vH6F5eYqaxgNEvDQdXRsOsfvoRyT3f/uJngPNJmcDmw= 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= -charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971/go.mod h1:i61Y3FmdbcBNSKa+pKB3DaE4uVQmBLMs/xlvRyHcXAE= +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea h1:XBmpGhIKPN8o9VjuXg+X5WXFsEqUs/YtPx0Q0zzmTTA= +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea/go.mod h1:xylWHUuJWcFJqoGrKdZP8Z0y3THC6xqrnfl1IYDviTE= charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da h1:vZa/Ow0uLclpfaDY0ubjzE+B0eLQqi2zanmpeALanow= charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da/go.mod h1:Tj12StbPc4GwksDF6XwhC9wdXouinIVxRGKKmmmzdSU= charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q= @@ -98,8 +98,8 @@ 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/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= @@ -443,8 +443,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= From faec9a21472d2e5adbcacff16264b74a24546825 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:58:13 -0300 Subject: [PATCH 122/125] chore(legal): @wallacegibbon 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 2256f849c9b0ecba1833dfe26ef91e9109c20375..93395e6498d3c35c3e24f0b9504c2ea362ad05a8 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1231,6 +1231,14 @@ "created_at": "2026-02-10T15:39:14Z", "repoId": 987670088, "pullRequestNo": 2183 + }, + { + "name": "wallacegibbon", + "id": 22029486, + "comment_id": 3890517245, + "created_at": "2026-02-12T11:58:04Z", + "repoId": 987670088, + "pullRequestNo": 2203 } ] } \ No newline at end of file From d7e225282c3d4b5a4a9d3ecea08240d439fa2d8b Mon Sep 17 00:00:00 2001 From: Austin Cherry Date: Thu, 12 Feb 2026 07:06:29 -0600 Subject: [PATCH 123/125] perf: replace regex-based gitignore with glob-based matching (#2199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace github.com/sabhiram/go-gitignore (regex-based) with github.com/go-git/go-git/v5/plumbing/format/gitignore (glob-based). Key optimizations: - Two-level caching: per-directory pattern cache + combined matcher cache - O(1) fast-path for common directories (node_modules, .git, __pycache__, etc.) - Pre-build combined matchers to avoid O(depth) pattern walking per file - Proper isDir parameter for directory-specific patterns (e.g., "backup/") Profiling showed 80% CPU in regexp.tryBacktrack from the old library when walking large monorepos (771k files). After this change, gitignore matching drops to ~2% of CPU time. 💘 Generated with Crush Assisted-by: AWS Claude Opus 4.5 via Crush --- go.mod | 7 +- go.sum | 16 +- internal/fsext/fileutil.go | 31 ++-- internal/fsext/ignore_test.go | 6 +- internal/fsext/ls.go | 276 +++++++++++++++++++--------------- internal/fsext/ls_test.go | 5 +- 6 files changed, 198 insertions(+), 143 deletions(-) diff --git a/go.mod b/go.mod index 2d5e7c78db09abe77f7aef404c051e57e24043cd..13493454d8b6a0583b834bc6aa19a5c73be83327 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.1 + github.com/go-git/go-git/v5 v5.16.5 github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 @@ -53,7 +54,6 @@ require ( 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 github.com/sahilm/fuzzy v0.1.1 github.com/sourcegraph/jsonrpc2 v0.2.1 github.com/spf13/cobra v1.10.2 @@ -110,6 +110,8 @@ require ( github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -127,6 +129,7 @@ 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/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kaptinlin/go-i18n v0.2.3 // indirect github.com/kaptinlin/jsonpointer v0.4.9 // indirect github.com/kaptinlin/jsonschema v0.6.10 // indirect @@ -149,7 +152,6 @@ require ( github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect @@ -185,6 +187,7 @@ require ( google.golang.org/protobuf v1.36.10 // indirect gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 8469b97cf1ce51255d4194799f9231bfaf74fa59..f29030cec4205073c997229175b4fc70d65e1934 100644 --- a/go.sum +++ b/go.sum @@ -164,6 +164,12 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= +github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= @@ -215,6 +221,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:on25kP+Sx7sxUMRQiA8gdcToAGet4DK/EIA30mXre+4= @@ -286,6 +294,8 @@ github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFu github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -303,8 +313,6 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= -github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= @@ -324,7 +332,6 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= @@ -502,10 +509,11 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= diff --git a/internal/fsext/fileutil.go b/internal/fsext/fileutil.go index 182b145a609311d20544d399c1212097c7519dda..c091820935d9c13142b12d3bd79d8c023a42a2fd 100644 --- a/internal/fsext/fileutil.go +++ b/internal/fsext/fileutil.go @@ -70,10 +70,16 @@ func NewFastGlobWalker(searchPath string) *FastGlobWalker { } } -// ShouldSkip checks if a path should be skipped based on hierarchical gitignore, -// crushignore, and hidden file rules +// ShouldSkip checks if a file path should be skipped based on hierarchical gitignore, +// crushignore, and hidden file rules. func (w *FastGlobWalker) ShouldSkip(path string) bool { - return w.directoryLister.shouldIgnore(path, nil) + return w.directoryLister.shouldIgnore(path, nil, false) +} + +// ShouldSkipDir checks if a directory path should be skipped based on hierarchical +// gitignore, crushignore, and hidden file rules. +func (w *FastGlobWalker) ShouldSkipDir(path string) bool { + return w.directoryLister.shouldIgnore(path, nil, true) } func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) { @@ -93,14 +99,15 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, return nil // Skip files we can't access } - if d.IsDir() { - if walker.ShouldSkip(path) { + isDir := d.IsDir() + if isDir { + if walker.ShouldSkipDir(path) { return filepath.SkipDir } - } - - if walker.ShouldSkip(path) { - return nil + } else { + if walker.ShouldSkip(path) { + return nil + } } relPath, err := filepath.Rel(searchPath, path) @@ -145,10 +152,12 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, } // ShouldExcludeFile checks if a file should be excluded from processing -// based on common patterns and ignore rules +// based on common patterns and ignore rules. func ShouldExcludeFile(rootPath, filePath string) bool { + info, err := os.Stat(filePath) + isDir := err == nil && info.IsDir() return NewDirectoryLister(rootPath). - shouldIgnore(filePath, nil) + shouldIgnore(filePath, nil, isDir) } func PrettyPath(path string) string { diff --git a/internal/fsext/ignore_test.go b/internal/fsext/ignore_test.go index a652f3a285fd256840fb3a711fb36e0217a43e28..e5e34e85a4c9bf0b207703aa0cf2f0459e03a427 100644 --- a/internal/fsext/ignore_test.go +++ b/internal/fsext/ignore_test.go @@ -21,9 +21,9 @@ func TestCrushIgnore(t *testing.T) { require.NoError(t, os.WriteFile(".crushignore", []byte("*.log\n"), 0o644)) dl := NewDirectoryLister(tempDir) - require.True(t, dl.shouldIgnore("test2.log", nil), ".log files should be ignored") - require.False(t, dl.shouldIgnore("test1.txt", nil), ".txt files should not be ignored") - require.True(t, dl.shouldIgnore("test3.tmp", nil), ".tmp files should be ignored by common patterns") + require.True(t, dl.shouldIgnore("test2.log", nil, false), ".log files should be ignored") + require.False(t, dl.shouldIgnore("test1.txt", nil, false), ".txt files should not be ignored") + require.True(t, dl.shouldIgnore("test3.tmp", nil, false), ".tmp files should be ignored by common patterns") } func TestShouldExcludeFile(t *testing.T) { diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go index b541a4a0fedd78c866fa274fc183fabe4c833edd..afc81bc156205dcc624a3a45f693047bb5be835e 100644 --- a/internal/fsext/ls.go +++ b/internal/fsext/ls.go @@ -12,29 +12,45 @@ import ( "github.com/charlievieth/fastwalk" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/home" - ignore "github.com/sabhiram/go-gitignore" + "github.com/go-git/go-git/v5/plumbing/format/gitignore" ) -// commonIgnorePatterns contains commonly ignored files and directories -var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser { - return ignore.CompileIgnoreLines( - // Version control - ".git", - ".svn", - ".hg", - ".bzr", - - // IDE and editor files - ".vscode", - ".idea", +// fastIgnoreDirs is a set of directory names that are always ignored. +// This provides O(1) lookup for common cases to avoid expensive pattern matching. +var fastIgnoreDirs = map[string]bool{ + ".git": true, + ".svn": true, + ".hg": true, + ".bzr": true, + ".vscode": true, + ".idea": true, + "node_modules": true, + "__pycache__": true, + ".pytest_cache": true, + ".cache": true, + ".tmp": true, + ".Trash": true, + ".Spotlight-V100": true, + ".fseventsd": true, + ".crush": true, + "OrbStack": true, + ".local": true, + ".share": true, +} + +// commonIgnorePatterns contains commonly ignored files and directories. +// Note: Exact directory names that are in fastIgnoreDirs are handled there for O(1) lookup. +// This list contains wildcard patterns and file-specific patterns. +var commonIgnorePatterns = sync.OnceValue(func() []gitignore.Pattern { + patterns := []string{ + // IDE and editor files (wildcards) "*.swp", "*.swo", "*~", ".DS_Store", "Thumbs.db", - // Build artifacts and dependencies - "node_modules", + // Build artifacts (non-fastIgnoreDirs) "target", "build", "dist", @@ -47,84 +63,147 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser { "*.dll", "*.exe", - // Logs and temporary files + // Logs and temporary files (wildcards) "*.log", "*.tmp", "*.temp", - ".cache", - ".tmp", - // Language-specific - "__pycache__", + // Language-specific (wildcards and non-fastIgnoreDirs) "*.pyc", "*.pyo", - ".pytest_cache", "vendor", "Cargo.lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", - - // OS generated files - ".Trash", - ".Spotlight-V100", - ".fseventsd", - - // Crush - ".crush", - - // macOS stuff - "OrbStack", - ".local", - ".share", - ) + } + return parsePatterns(patterns, nil) }) -var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser { - home := home.Dir() +var homeIgnorePatterns = sync.OnceValue(func() []gitignore.Pattern { + homeDir := home.Dir() var lines []string for _, name := range []string{ - filepath.Join(home, ".gitignore"), - filepath.Join(home, ".config", "git", "ignore"), - filepath.Join(home, ".config", "crush", "ignore"), + filepath.Join(homeDir, ".gitignore"), + filepath.Join(homeDir, ".config", "git", "ignore"), + filepath.Join(homeDir, ".config", "crush", "ignore"), } { if bts, err := os.ReadFile(name); err == nil { lines = append(lines, strings.Split(string(bts), "\n")...) } } - return ignore.CompileIgnoreLines(lines...) + return parsePatterns(lines, nil) }) +// parsePatterns parses gitignore pattern strings into Pattern objects. +// domain is the path components where the patterns are defined (nil for global). +func parsePatterns(lines []string, domain []string) []gitignore.Pattern { + var patterns []gitignore.Pattern + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + patterns = append(patterns, gitignore.ParsePattern(line, domain)) + } + return patterns +} + type directoryLister struct { - ignores *csync.Map[string, ignore.IgnoreParser] - rootPath string + // dirPatterns caches parsed patterns from .gitignore/.crushignore for each directory. + // This avoids re-reading files when building combined matchers. + dirPatterns *csync.Map[string, []gitignore.Pattern] + // combinedMatchers caches a combined matcher for each directory that includes + // all ancestor patterns. This allows O(1) matching per file. + combinedMatchers *csync.Map[string, gitignore.Matcher] + rootPath string } func NewDirectoryLister(rootPath string) *directoryLister { - dl := &directoryLister{ - rootPath: rootPath, - ignores: csync.NewMap[string, ignore.IgnoreParser](), + return &directoryLister{ + rootPath: rootPath, + dirPatterns: csync.NewMap[string, []gitignore.Pattern](), + combinedMatchers: csync.NewMap[string, gitignore.Matcher](), } - dl.getIgnore(rootPath) - return dl } -// git checks, in order: -// - ./.gitignore, ../.gitignore, etc, until repo root -// ~/.config/git/ignore -// ~/.gitignore -// -// This will do the following: -// - the given ignorePatterns -// - [commonIgnorePatterns] -// - ./.gitignore, ../.gitignore, etc, until dl.rootPath -// - ./.crushignore, ../.crushignore, etc, until dl.rootPath -// ~/.config/git/ignore -// ~/.gitignore -// ~/.config/crush/ignore -func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool { +// pathToComponents splits a path into its components for gitignore matching. +func pathToComponents(path string) []string { + path = filepath.ToSlash(path) + if path == "" || path == "." { + return nil + } + return strings.Split(path, "/") +} + +// getDirPatterns returns the parsed patterns for a specific directory's +// .gitignore and .crushignore files. Results are cached. +func (dl *directoryLister) getDirPatterns(dir string) []gitignore.Pattern { + return dl.dirPatterns.GetOrSet(dir, func() []gitignore.Pattern { + var allPatterns []gitignore.Pattern + + relPath, _ := filepath.Rel(dl.rootPath, dir) + var domain []string + if relPath != "" && relPath != "." { + domain = pathToComponents(relPath) + } + + for _, ignoreFile := range []string{".gitignore", ".crushignore"} { + ignPath := filepath.Join(dir, ignoreFile) + if content, err := os.ReadFile(ignPath); err == nil { + lines := strings.Split(string(content), "\n") + allPatterns = append(allPatterns, parsePatterns(lines, domain)...) + } + } + return allPatterns + }) +} + +// getCombinedMatcher returns a matcher that combines all gitignore patterns +// from the root to the given directory, plus common patterns and home patterns. +// Results are cached per directory, and we reuse parent directory matchers. +func (dl *directoryLister) getCombinedMatcher(dir string) gitignore.Matcher { + return dl.combinedMatchers.GetOrSet(dir, func() gitignore.Matcher { + var allPatterns []gitignore.Pattern + + // Add common patterns first (lowest priority). + allPatterns = append(allPatterns, commonIgnorePatterns()...) + + // Add home ignore patterns. + allPatterns = append(allPatterns, homeIgnorePatterns()...) + + // Collect patterns from root to this directory. + relDir, _ := filepath.Rel(dl.rootPath, dir) + var pathParts []string + if relDir != "" && relDir != "." { + pathParts = pathToComponents(relDir) + } + + // Add patterns from each directory from root to current. + currentPath := dl.rootPath + allPatterns = append(allPatterns, dl.getDirPatterns(currentPath)...) + + for _, part := range pathParts { + currentPath = filepath.Join(currentPath, part) + allPatterns = append(allPatterns, dl.getDirPatterns(currentPath)...) + } + + return gitignore.NewMatcher(allPatterns) + }) +} + +// shouldIgnore checks if a path should be ignored based on gitignore rules. +// This uses a combined matcher that includes all ancestor patterns for O(1) matching. +func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string, isDir bool) bool { + base := filepath.Base(path) + + // Fast path: O(1) lookup for commonly ignored directories. + if isDir && fastIgnoreDirs[base] { + return true + } + + // Check explicit ignore patterns. if len(ignorePatterns) > 0 { - base := filepath.Base(path) for _, pattern := range ignorePatterns { if matched, err := filepath.Match(pattern, base); err == nil && matched { return true @@ -132,8 +211,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo } } - // Don't apply gitignore rules to the root directory itself - // In gitignore semantics, patterns don't apply to the repo root + // Don't apply gitignore rules to the root directory itself. if path == dl.rootPath { return false } @@ -143,69 +221,24 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo relPath = path } - if commonIgnorePatterns().MatchesPath(relPath) { - slog.Debug("Ignoring common pattern", "path", relPath) - return true + pathComponents := pathToComponents(relPath) + if len(pathComponents) == 0 { + return false } + // Get the combined matcher for the parent directory. parentDir := filepath.Dir(path) - ignoreParser := dl.getIgnore(parentDir) - if ignoreParser.MatchesPath(relPath) { - slog.Debug("Ignoring dir pattern", "path", relPath, "dir", parentDir) - return true - } + matcher := dl.getCombinedMatcher(parentDir) - // For directories, also check with trailing slash (gitignore convention) - if ignoreParser.MatchesPath(relPath + "/") { - slog.Debug("Ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir) + if matcher.Match(pathComponents, isDir) { + slog.Debug("Ignoring path", "path", relPath) return true } - if dl.checkParentIgnores(relPath) { - return true - } - - if homeIgnore().MatchesPath(relPath) { - slog.Debug("Ignoring home dir pattern", "path", relPath) - return true - } - - return false -} - -func (dl *directoryLister) checkParentIgnores(path string) bool { - parent := filepath.Dir(filepath.Dir(path)) - for parent != "." && path != "." { - if dl.getIgnore(parent).MatchesPath(path) { - slog.Debug("Ignoring parent dir pattern", "path", path, "dir", parent) - return true - } - if parent == dl.rootPath { - break - } - parent = filepath.Dir(parent) - } return false } -func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser { - return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser { - var lines []string - for _, ign := range []string{".crushignore", ".gitignore"} { - name := filepath.Join(path, ign) - if content, err := os.ReadFile(name); err == nil { - lines = append(lines, strings.Split(string(content), "\n")...) - } - } - if len(lines) == 0 { - // Return a no-op parser to avoid nil checks - return ignore.CompileIgnoreLines() - } - return ignore.CompileIgnoreLines(lines...) - }) -} - -// ListDirectory lists files and directories in the specified path, +// ListDirectory lists files and directories in the specified path. func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) { found := csync.NewSlice[string]() dl := NewDirectoryLister(initialPath) @@ -224,15 +257,16 @@ func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int return nil // Skip files we don't have permission to access } - if dl.shouldIgnore(path, ignorePatterns) { - if d.IsDir() { + isDir := d.IsDir() + if dl.shouldIgnore(path, ignorePatterns, isDir) { + if isDir { return filepath.SkipDir } return nil } if path != initialPath { - if d.IsDir() { + if isDir { path = path + string(filepath.Separator) } found.Append(path) diff --git a/internal/fsext/ls_test.go b/internal/fsext/ls_test.go index 7bdad17fc46955d49fa08f7488d6efe8239294cb..1bb862754ea38c378fa75709f9dd4b30dd8803f8 100644 --- a/internal/fsext/ls_test.go +++ b/internal/fsext/ls_test.go @@ -31,11 +31,12 @@ func TestListDirectory(t *testing.T) { files, truncated, err := ListDirectory(tmp, nil, -1, -1) require.NoError(t, err) require.False(t, truncated) - require.Len(t, files, 4) + // The .gitignore has ".*" pattern which ignores hidden files anywhere + // (like real git does), so subdir/.another is ignored. + require.Len(t, files, 3) require.ElementsMatch(t, []string{ "regular.txt", "subdir", - "subdir/.another", "subdir/file.go", }, relPaths(t, files, tmp)) }) From eae61e16b238063ee1d8c4a03ce396e011dd8d0e Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 12 Feb 2026 11:14:49 -0300 Subject: [PATCH 124/125] fix(ui): completions offset for attachments row (#2208) refs #2129 Signed-off-by: Carlos Alexandro Becker --- internal/ui/model/ui.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index b8d94a12bb57c03cdb814a2346b117a01acfdd92..0545aa3eca7fb661e6f8e6c906af094bcbd44bc1 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1889,7 +1889,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { x = screenW - w } x = max(0, x) - y = max(0, y) + y = max(0, y+1) // Offset for attachments row completionsView := uv.NewStyledString(m.completions.Render()) completionsView.Draw(scr, image.Rectangle{ From 8ccb3c70918b2affbe0b0f96717e972313f2cd8f Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 12 Feb 2026 10:46:20 -0500 Subject: [PATCH 125/125] feat(pills): add toggle todos/pills menu item (#2202) --- internal/session/session.go | 10 ++++++++++ internal/ui/dialog/actions.go | 1 + internal/ui/dialog/commands.go | 35 +++++++++++++++++++++++++++------- internal/ui/model/pills.go | 7 +------ internal/ui/model/ui.go | 14 +++++++++++--- 5 files changed, 51 insertions(+), 16 deletions(-) diff --git a/internal/session/session.go b/internal/session/session.go index 0ef6cfe22bebbf35df48f0db1fbe00c6d128251b..f9279f9f4d45f8562fd868074721d27dca0901f6 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -28,6 +28,16 @@ type Todo struct { ActiveForm string `json:"active_form"` } +// HasIncompleteTodos returns true if there are any non-completed todos. +func HasIncompleteTodos(todos []Todo) bool { + for _, todo := range todos { + if todo.Status != TodoStatusCompleted { + return true + } + } + return false +} + type Session struct { ID string ParentSessionID string diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 5c96f1c96111222a270a4529d39bfaac4162205c..79e11a64bec50937b36a198b6096f83273041142 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -48,6 +48,7 @@ type ( ActionToggleHelp struct{} ActionToggleCompactMode struct{} ActionToggleThinking struct{} + ActionTogglePills struct{} ActionExternalEditor struct{} ActionToggleYoloMode struct{} // ActionInitializeProject is a message to initialize a project. diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 6e769e66f7217c994f877582e8ca2eca80577b9a..e74d92775de24ee2672b251005e1e6d501e35aab 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -49,8 +49,11 @@ type Commands struct { Close key.Binding } - sessionID string // can be empty for non-session-specific commands - selected CommandType + sessionID string + hasSession bool + hasTodos bool + hasQueue bool + selected CommandType spinner spinner.Model loading bool @@ -68,11 +71,14 @@ type Commands struct { var _ Dialog = (*Commands)(nil) // NewCommands creates a new commands dialog. -func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) { +func NewCommands(com *common.Common, sessionID string, hasSession, hasTodos, hasQueue bool, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) { c := &Commands{ com: com, selected: SystemCommands, sessionID: sessionID, + hasSession: hasSession, + hasTodos: hasTodos, + hasQueue: hasQueue, customCommands: customCommands, mcpPrompts: mcpPrompts, } @@ -387,7 +393,7 @@ func (c *Commands) defaultCommands() []*CommandItem { } // Only show compact command if there's an active session - if c.sessionID != "" { + if c.hasSession { commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID})) } @@ -417,10 +423,10 @@ 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.hasSession { commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{})) } - if c.sessionID != "" { + if c.hasSession { cfg := c.com.Config() agentCfg := cfg.Agents[config.AgentCoder] model := cfg.GetModelByType(agentCfg.Model) @@ -437,12 +443,27 @@ func (c *Commands) defaultCommands() []*CommandItem { commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{})) } - return append(commands, + if c.hasTodos || c.hasQueue { + var label string + switch { + case c.hasTodos && c.hasQueue: + label = "Toggle To-Dos/Queue" + case c.hasQueue: + label = "Toggle Queue" + default: + label = "Toggle To-Dos" + } + commands = append(commands, NewCommandItem(c.com.Styles, "toggle_pills", label, "ctrl+t", ActionTogglePills{})) + } + + commands = append(commands, NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}), NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}), NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}), NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}), ) + + return commands } // SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed. diff --git a/internal/ui/model/pills.go b/internal/ui/model/pills.go index 6dc87fabd3f14733f3da7e56eb1382df0d825b94..fb3dcc1e3a86cb63d9e0a267476863a6260d0816 100644 --- a/internal/ui/model/pills.go +++ b/internal/ui/model/pills.go @@ -38,12 +38,7 @@ const ( // hasIncompleteTodos returns true if there are any non-completed todos. func hasIncompleteTodos(todos []session.Todo) bool { - for _, todo := range todos { - if todo.Status != session.TodoStatusCompleted { - return true - } - } - return false + return session.HasIncompleteTodos(todos) } // hasInProgressTodo returns true if there is at least one in-progress todo. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 0545aa3eca7fb661e6f8e6c906af094bcbd44bc1..04b644361be47ed223201ba9d0744e084feb7119 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1213,6 +1213,11 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { case dialog.ActionToggleCompactMode: cmds = append(cmds, m.toggleCompactMode()) m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionTogglePills: + if cmd := m.togglePillsExpanded(); cmd != nil { + cmds = append(cmds, cmd) + } + m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionToggleThinking: cmds = append(cmds, func() tea.Msg { cfg := m.com.Config() @@ -2858,12 +2863,15 @@ func (m *UI) openCommandsDialog() tea.Cmd { return nil } - sessionID := "" - if m.session != nil { + var sessionID string + hasSession := m.session != nil + if hasSession { sessionID = m.session.ID } + hasTodos := hasSession && hasIncompleteTodos(m.session.Todos) + hasQueue := m.promptQueue > 0 - commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts) + commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts) if err != nil { return util.ReportError(err) }