From 3c8be6926cda50f4129e358bf78af65e7b315d32 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 3 Feb 2026 16:06:24 -0300 Subject: [PATCH 01/80] 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 03/80] 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 04/80] 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 05/80] 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 06/80] 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 07/80] 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 08/80] 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 09/80] 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 11/80] 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 12/80] 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 13/80] 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 14/80] 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 15/80] 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 16/80] 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 17/80] 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 18/80] 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 19/80] 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 20/80] 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 21/80] 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 22/80] 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 24/80] 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 26/80] 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 27/80] 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 28/80] 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 29/80] 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 30/80] 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 31/80] 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 32/80] 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 33/80] 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 34/80] 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 35/80] 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 36/80] 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 37/80] 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 38/80] 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 39/80] 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 40/80] 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 41/80] 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 42/80] 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 43/80] 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 44/80] 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 45/80] 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 46/80] 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 47/80] 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 48/80] 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 49/80] 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 50/80] 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 51/80] 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 52/80] 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 53/80] 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 54/80] 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 55/80] 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 57/80] 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 58/80] 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 59/80] 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 61/80] 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 62/80] 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 63/80] 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 64/80] 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 65/80] 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 66/80] 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 67/80] 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 68/80] 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 69/80] 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 70/80] 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 71/80] 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 72/80] 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 73/80] 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 74/80] 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 75/80] 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 76/80] 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 77/80] 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 78/80] 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 0ed58061f8fe5252063b4f413562a3a6ea9b36cf Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 11 Feb 2026 09:03:08 -0300 Subject: [PATCH 79/80] 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 80/80] 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