From 27fa971aff9352dc74acf834d2a5ca87800d4f8b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 22 Oct 2025 16:22:26 -0400 Subject: [PATCH 001/335] feat(ui): add basic dialog and new UI structure --- internal/cmd/root.go | 5 +- internal/ui/common/common.go | 9 +++ internal/ui/dialog/dialog.go | 123 +++++++++++++++++++++++++++++++++++ internal/ui/dialog/keymap.go | 57 ++++++++++++++++ internal/ui/dialog/quit.go | 108 ++++++++++++++++++++++++++++++ internal/ui/keys.go | 36 ++++++++++ internal/ui/ui.go | 103 +++++++++++++++++++++++++++++ 7 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 internal/ui/common/common.go create mode 100644 internal/ui/dialog/dialog.go create mode 100644 internal/ui/dialog/keymap.go create mode 100644 internal/ui/dialog/quit.go create mode 100644 internal/ui/keys.go create mode 100644 internal/ui/ui.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 005f2e86f7012b265fb619580c7cc2eec2e4de03..fbd86bd7592d4bd8a65820d2131fdb9873de8200 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -18,6 +18,7 @@ import ( "github.com/charmbracelet/crush/internal/db" "github.com/charmbracelet/crush/internal/event" "github.com/charmbracelet/crush/internal/tui" + "github.com/charmbracelet/crush/internal/ui" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/fang" "github.com/charmbracelet/lipgloss/v2" @@ -81,8 +82,10 @@ crush -y event.AppInitialized() // Set up the TUI. + // ui := tui.New(app) + ui := ui.New(app) program := tea.NewProgram( - tui.New(app), + ui, tea.WithContext(cmd.Context()), tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go new file mode 100644 index 0000000000000000000000000000000000000000..095959b2b34bf864a4bf400d956f63a66b1afbf5 --- /dev/null +++ b/internal/ui/common/common.go @@ -0,0 +1,9 @@ +package common + +import tea "github.com/charmbracelet/bubbletea/v2" + +// Model represents a common interface for UI components. +type Model[T any] interface { + Update(msg tea.Msg) (T, tea.Cmd) + View() string +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go new file mode 100644 index 0000000000000000000000000000000000000000..6276bc7c66dd6cbfd99896bc8987958388b7e87e --- /dev/null +++ b/internal/ui/dialog/dialog.go @@ -0,0 +1,123 @@ +package dialog + +import ( + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/lipgloss/v2" +) + +// Model is a component that can be displayed on top of the UI. +type Model interface { + common.Model[Model] + ID() string +} + +// Overlay manages multiple dialogs as an overlay. +type Overlay struct { + dialogs []Model + keyMap KeyMap +} + +// NewOverlay creates a new [Overlay] instance. +func NewOverlay(dialogs ...Model) *Overlay { + return &Overlay{ + dialogs: dialogs, + keyMap: DefaultKeyMap(), + } +} + +// ContainsDialog checks if a dialog with the specified ID exists. +func (d *Overlay) ContainsDialog(dialogID string) bool { + for _, dialog := range d.dialogs { + if dialog.ID() == dialogID { + return true + } + } + return false +} + +// AddDialog adds a new dialog to the stack. +func (d *Overlay) AddDialog(dialog Model) { + d.dialogs = append(d.dialogs, dialog) +} + +// BringToFront brings the dialog with the specified ID to the front. +func (d *Overlay) BringToFront(dialogID string) { + for i, dialog := range d.dialogs { + if dialog.ID() == dialogID { + // Move the dialog to the end of the slice + d.dialogs = append(d.dialogs[:i], d.dialogs[i+1:]...) + d.dialogs = append(d.dialogs, dialog) + return + } + } +} + +// Update handles dialog updates. +func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) { + if len(d.dialogs) == 0 { + return d, nil + } + + idx := len(d.dialogs) - 1 // active dialog is the last one + dialog := d.dialogs[idx] + switch msg := msg.(type) { + case tea.KeyPressMsg: + if key.Matches(msg, d.keyMap.Close) { + // Close the current dialog + d.removeDialog(idx) + return d, nil + } + } + + updatedDialog, cmd := dialog.Update(msg) + if updatedDialog == nil { + // Dialog requested to be closed + d.removeDialog(idx) + return d, cmd + } + + // Update the dialog in the stack + d.dialogs[idx] = updatedDialog + + return d, cmd +} + +// View implements [Model]. +func (d *Overlay) View() string { + if len(d.dialogs) == 0 { + return "" + } + + // Compose all the dialogs into a single view + canvas := lipgloss.NewCanvas() + for _, dialog := range d.dialogs { + layer := lipgloss.NewLayer(dialog.View()) + canvas.AddLayers(layer) + } + + return canvas.Render() +} + +// ShortHelp implements [help.KeyMap]. +func (d *Overlay) ShortHelp() []key.Binding { + return []key.Binding{ + d.keyMap.Close, + } +} + +// FullHelp implements [help.KeyMap]. +func (d *Overlay) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {d.keyMap.Close}, + } +} + +// removeDialog removes a dialog from the stack. +func (d *Overlay) removeDialog(idx int) { + if idx < 0 || idx >= len(d.dialogs) { + return + } + d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...) +} diff --git a/internal/ui/dialog/keymap.go b/internal/ui/dialog/keymap.go new file mode 100644 index 0000000000000000000000000000000000000000..cd52ec88f9fea060f19335c78e6574ee1bd17b21 --- /dev/null +++ b/internal/ui/dialog/keymap.go @@ -0,0 +1,57 @@ +package dialog + +import "github.com/charmbracelet/bubbles/v2/key" + +// KeyMap defines key bindings for dialogs. +type KeyMap struct { + Close key.Binding +} + +// DefaultKeyMap returns the default key bindings for dialogs. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Close: key.NewBinding( + key.WithKeys("esc", "alt+esc"), + ), + } +} + +// QuitKeyMap represents key bindings for the quit dialog. +type QuitKeyMap struct { + LeftRight, + EnterSpace, + Yes, + No, + Tab, + Close key.Binding +} + +// DefaultQuitKeyMap returns the default key bindings for the quit dialog. +func DefaultQuitKeyMap() QuitKeyMap { + return QuitKeyMap{ + 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"), + ), + } +} diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go new file mode 100644 index 0000000000000000000000000000000000000000..48f92a59817440f9d373d9e504e83ef719f11f7a --- /dev/null +++ b/internal/ui/dialog/quit.go @@ -0,0 +1,108 @@ +package dialog + +import ( + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" +) + +// Quit represents a confirmation dialog for quitting the application. +type Quit struct { + keyMap QuitKeyMap + selectedNo bool // true if "No" button is selected +} + +// NewQuit creates a new quit confirmation dialog. +func NewQuit() *Quit { + q := &Quit{ + keyMap: DefaultQuitKeyMap(), + } + return q +} + +// ID implements [Model]. +func (*Quit) ID() string { + return "quit" +} + +// Update implements [Model]. +func (q *Quit) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + 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 nil, nil + case key.Matches(msg, q.keyMap.Yes): + return q, tea.Quit + case key.Matches(msg, q.keyMap.No, q.keyMap.Close): + return nil, nil + } + } + + return q, nil +} + +// View implements [Model]. +func (q *Quit) View() string { + const question = "Are you sure you want to quit?" + + baseStyle := lipgloss.NewStyle() + yesStyle := lipgloss.NewStyle() + noStyle := yesStyle + + if q.selectedNo { + noStyle = noStyle.Foreground(lipgloss.Color("15")).Background(lipgloss.Color("15")) + yesStyle = yesStyle.Background(lipgloss.Color("15")) + } else { + yesStyle = yesStyle.Foreground(lipgloss.Color("15")).Background(lipgloss.Color("15")) + noStyle = noStyle.Background(lipgloss.Color("15")) + } + + 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(lipgloss.Color("15")) + + return quitDialogStyle.Render(content) +} + +// ShortHelp implements [help.KeyMap]. +func (q *Quit) ShortHelp() []key.Binding { + return []key.Binding{ + q.keyMap.LeftRight, + q.keyMap.EnterSpace, + } +} + +// FullHelp implements [help.KeyMap]. +func (q *Quit) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {q.keyMap.LeftRight, q.keyMap.EnterSpace, q.keyMap.Yes, q.keyMap.No}, + {q.keyMap.Tab, q.keyMap.Close}, + } +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..335840fa06c7927588410c71140d3537881a434b --- /dev/null +++ b/internal/ui/keys.go @@ -0,0 +1,36 @@ +package ui + +import "github.com/charmbracelet/bubbles/v2/key" + +type KeyMap struct { + Quit key.Binding + Help key.Binding + Commands key.Binding + Suspend key.Binding + Sessions 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"), + ), + Sessions: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "sessions"), + ), + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000000000000000000000000000000000000..2c2ce28e0d2a5f144a8025a43a6c9a01ae778c65 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,103 @@ +package ui + +import ( + "image" + + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/ui/dialog" + "github.com/charmbracelet/lipgloss/v2" + uv "github.com/charmbracelet/ultraviolet" +) + +type uiState uint8 + +const ( + uiStateMain uiState = iota +) + +type Model struct { + app *app.App + width, height int + state uiState + + keyMap KeyMap + + dialog *dialog.Overlay +} + +func New(app *app.App) *Model { + return &Model{ + app: app, + dialog: dialog.NewOverlay(), + keyMap: DefaultKeyMap(), + } +} + +func (m *Model) Init() tea.Cmd { + return nil +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case tea.KeyPressMsg: + switch m.state { + case uiStateMain: + switch { + case key.Matches(msg, m.keyMap.Quit): + quitDialog := dialog.NewQuit() + if !m.dialog.ContainsDialog(quitDialog.ID()) { + m.dialog.AddDialog(quitDialog) + return m, nil + } + } + } + } + + updatedDialog, cmd := m.dialog.Update(msg) + m.dialog = updatedDialog + if cmd != nil { + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m *Model) View() tea.View { + var v tea.View + + // The screen area we're working with + area := image.Rect(0, 0, m.width, m.height) + layers := []*lipgloss.Layer{} + + if dialogView := m.dialog.View(); dialogView != "" { + dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView) + dialogArea := centerRect(area, dialogWidth, dialogHeight) + layers = append(layers, + lipgloss.NewLayer(dialogView). + X(dialogArea.Min.X). + Y(dialogArea.Min.Y), + ) + } + + v.Layer = lipgloss.NewCanvas(layers...) + + return v +} + +// centerRect returns a new [Rectangle] centered within the given area with the +// specified width and height. +func centerRect(area uv.Rectangle, width, height int) uv.Rectangle { + centerX := area.Min.X + area.Dx()/2 + centerY := area.Min.Y + area.Dy()/2 + minX := centerX - width/2 + minY := centerY - height/2 + maxX := minX + width + maxY := minY + height + return image.Rect(minX, minY, maxX, maxY) +} From 706e182730c2ea7542c0da0224c72b44c5d0db5d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 24 Oct 2025 12:00:36 -0400 Subject: [PATCH 002/335] refactor(ui): reorganize ui components into flat structure --- internal/ui/common.go | 17 ++ internal/ui/{dialog => }/dialog.go | 35 +++- internal/ui/dialog/keymap.go | 57 ----- internal/ui/{common/common.go => model.go} | 2 +- .../ui/{dialog/quit.go => quit_dialog.go} | 87 +++++--- internal/ui/styles.go | 194 ++++++++++++++++++ internal/ui/ui.go | 25 ++- 7 files changed, 310 insertions(+), 107 deletions(-) create mode 100644 internal/ui/common.go rename internal/ui/{dialog => }/dialog.go (77%) delete mode 100644 internal/ui/dialog/keymap.go rename internal/ui/{common/common.go => model.go} (92%) rename internal/ui/{dialog/quit.go => quit_dialog.go} (50%) create mode 100644 internal/ui/styles.go diff --git a/internal/ui/common.go b/internal/ui/common.go new file mode 100644 index 0000000000000000000000000000000000000000..72a0bae02667aad6a0b7028b2e1fcdd017549137 --- /dev/null +++ b/internal/ui/common.go @@ -0,0 +1,17 @@ +package ui + +import "github.com/charmbracelet/crush/internal/config" + +// Common defines common UI options and configurations. +type Common struct { + Config *config.Config + Styles Styles +} + +// DefaultCommon returns the default common UI configurations. +func DefaultCommon(cfg *config.Config) *Common { + return &Common{ + Config: cfg, + Styles: DefaultStyles(), + } +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog.go similarity index 77% rename from internal/ui/dialog/dialog.go rename to internal/ui/dialog.go index 6276bc7c66dd6cbfd99896bc8987958388b7e87e..bfae19d05b8f3fc53e7988c8c29ed2454ffb17a9 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog.go @@ -1,29 +1,42 @@ -package dialog +package ui import ( "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/lipgloss/v2" ) -// Model is a component that can be displayed on top of the UI. -type Model interface { - common.Model[Model] +// DialogOverlayKeyMap defines key bindings for dialogs. +type DialogOverlayKeyMap struct { + Close key.Binding +} + +// DefaultDialogOverlayKeyMap returns the default key bindings for dialogs. +func DefaultDialogOverlayKeyMap() DialogOverlayKeyMap { + return DialogOverlayKeyMap{ + Close: key.NewBinding( + key.WithKeys("esc", "alt+esc"), + ), + } +} + +// Dialog is a component that can be displayed on top of the UI. +type Dialog interface { + Model[Dialog] ID() string } // Overlay manages multiple dialogs as an overlay. type Overlay struct { - dialogs []Model - keyMap KeyMap + dialogs []Dialog + keyMap DialogOverlayKeyMap } -// NewOverlay creates a new [Overlay] instance. -func NewOverlay(dialogs ...Model) *Overlay { +// NewDialogOverlay creates a new [Overlay] instance. +func NewDialogOverlay(dialogs ...Dialog) *Overlay { return &Overlay{ dialogs: dialogs, - keyMap: DefaultKeyMap(), + keyMap: DefaultDialogOverlayKeyMap(), } } @@ -38,7 +51,7 @@ func (d *Overlay) ContainsDialog(dialogID string) bool { } // AddDialog adds a new dialog to the stack. -func (d *Overlay) AddDialog(dialog Model) { +func (d *Overlay) AddDialog(dialog Dialog) { d.dialogs = append(d.dialogs, dialog) } diff --git a/internal/ui/dialog/keymap.go b/internal/ui/dialog/keymap.go deleted file mode 100644 index cd52ec88f9fea060f19335c78e6574ee1bd17b21..0000000000000000000000000000000000000000 --- a/internal/ui/dialog/keymap.go +++ /dev/null @@ -1,57 +0,0 @@ -package dialog - -import "github.com/charmbracelet/bubbles/v2/key" - -// KeyMap defines key bindings for dialogs. -type KeyMap struct { - Close key.Binding -} - -// DefaultKeyMap returns the default key bindings for dialogs. -func DefaultKeyMap() KeyMap { - return KeyMap{ - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - ), - } -} - -// QuitKeyMap represents key bindings for the quit dialog. -type QuitKeyMap struct { - LeftRight, - EnterSpace, - Yes, - No, - Tab, - Close key.Binding -} - -// DefaultQuitKeyMap returns the default key bindings for the quit dialog. -func DefaultQuitKeyMap() QuitKeyMap { - return QuitKeyMap{ - 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"), - ), - } -} diff --git a/internal/ui/common/common.go b/internal/ui/model.go similarity index 92% rename from internal/ui/common/common.go rename to internal/ui/model.go index 095959b2b34bf864a4bf400d956f63a66b1afbf5..ec8876b9adb643530d732a6f92bab5d28a8c1938 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/model.go @@ -1,4 +1,4 @@ -package common +package ui import tea "github.com/charmbracelet/bubbletea/v2" diff --git a/internal/ui/dialog/quit.go b/internal/ui/quit_dialog.go similarity index 50% rename from internal/ui/dialog/quit.go rename to internal/ui/quit_dialog.go index 48f92a59817440f9d373d9e504e83ef719f11f7a..b099ba671f1df17ef7d96c19a94ec91d50d29d24 100644 --- a/internal/ui/dialog/quit.go +++ b/internal/ui/quit_dialog.go @@ -1,4 +1,4 @@ -package dialog +package ui import ( "github.com/charmbracelet/bubbles/v2/key" @@ -6,27 +6,69 @@ import ( "github.com/charmbracelet/lipgloss/v2" ) -// Quit represents a confirmation dialog for quitting the application. -type Quit struct { - keyMap QuitKeyMap +// QuitDialogKeyMap represents key bindings for the quit dialog. +type QuitDialogKeyMap struct { + LeftRight, + EnterSpace, + Yes, + No, + Tab, + Close key.Binding +} + +// DefaultQuitKeyMap returns the default key bindings for the quit dialog. +func DefaultQuitKeyMap() QuitDialogKeyMap { + return QuitDialogKeyMap{ + 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"), + ), + } +} + +// QuitDialog represents a confirmation dialog for quitting the application. +type QuitDialog struct { + com *Common + keyMap QuitDialogKeyMap selectedNo bool // true if "No" button is selected } -// NewQuit creates a new quit confirmation dialog. -func NewQuit() *Quit { - q := &Quit{ +// NewQuitDialog creates a new quit confirmation dialog. +func NewQuitDialog(com *Common) *QuitDialog { + q := &QuitDialog{ + com: com, keyMap: DefaultQuitKeyMap(), } return q } // ID implements [Model]. -func (*Quit) ID() string { +func (*QuitDialog) ID() string { return "quit" } // Update implements [Model]. -func (q *Quit) Update(msg tea.Msg) (Model, tea.Cmd) { +func (q *QuitDialog) Update(msg tea.Msg) (Dialog, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch { @@ -49,19 +91,15 @@ func (q *Quit) Update(msg tea.Msg) (Model, tea.Cmd) { } // View implements [Model]. -func (q *Quit) View() string { +func (q *QuitDialog) View() string { const question = "Are you sure you want to quit?" - - baseStyle := lipgloss.NewStyle() - yesStyle := lipgloss.NewStyle() - noStyle := yesStyle + baseStyle := q.com.Styles.Base + yesStyle := q.com.Styles.ButtonSelected + noStyle := q.com.Styles.ButtonUnselected if q.selectedNo { - noStyle = noStyle.Foreground(lipgloss.Color("15")).Background(lipgloss.Color("15")) - yesStyle = yesStyle.Background(lipgloss.Color("15")) - } else { - yesStyle = yesStyle.Foreground(lipgloss.Color("15")).Background(lipgloss.Color("15")) - noStyle = noStyle.Background(lipgloss.Color("15")) + noStyle = q.com.Styles.ButtonSelected + yesStyle = q.com.Styles.ButtonUnselected } const horizontalPadding = 3 @@ -83,16 +121,11 @@ func (q *Quit) View() string { ), ) - quitDialogStyle := baseStyle. - Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("15")) - - return quitDialogStyle.Render(content) + return q.com.Styles.BorderFocus.Render(content) } // ShortHelp implements [help.KeyMap]. -func (q *Quit) ShortHelp() []key.Binding { +func (q *QuitDialog) ShortHelp() []key.Binding { return []key.Binding{ q.keyMap.LeftRight, q.keyMap.EnterSpace, @@ -100,7 +133,7 @@ func (q *Quit) ShortHelp() []key.Binding { } // FullHelp implements [help.KeyMap]. -func (q *Quit) FullHelp() [][]key.Binding { +func (q *QuitDialog) FullHelp() [][]key.Binding { return [][]key.Binding{ {q.keyMap.LeftRight, q.keyMap.EnterSpace, q.keyMap.Yes, q.keyMap.No}, {q.keyMap.Tab, q.keyMap.Close}, diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 0000000000000000000000000000000000000000..11a3871317b720f63b1819a1a7590e6282797e18 --- /dev/null +++ b/internal/ui/styles.go @@ -0,0 +1,194 @@ +package ui + +import ( + "github.com/charmbracelet/bubbles/v2/filepicker" + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/textarea" + "github.com/charmbracelet/bubbles/v2/textinput" + "github.com/charmbracelet/crush/internal/tui/exp/diffview" + "github.com/charmbracelet/glamour/v2/ansi" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/exp/charmtone" +) + +const ( + CheckIcon string = "✓" + ErrorIcon string = "×" + WarningIcon string = "⚠" + InfoIcon string = "ⓘ" + HintIcon string = "∵" + SpinnerIcon string = "..." + LoadingIcon string = "⟳" + DocumentIcon string = "🖼" + ModelIcon string = "◇" + + ToolPending string = "●" + ToolSuccess string = "✓" + ToolError string = "×" + + BorderThin string = "│" + BorderThick string = "▌" +) + +type Styles struct { + WindowTooSmall lipgloss.Style + + // Reusable text styles + Base lipgloss.Style + Muted lipgloss.Style + Subtle lipgloss.Style + + // Tags + TagBase lipgloss.Style + TagError lipgloss.Style + TagInfo lipgloss.Style + + // Headers + HeaderTool lipgloss.Style + HeaderToolNested lipgloss.Style + + // Panels + PanelMuted lipgloss.Style + PanelBase lipgloss.Style + + // Line numbers for code blocks + LineNumber lipgloss.Style + + // Message borders + FocusedMessageBorder lipgloss.Border + + // Tool calls + ToolCallPending lipgloss.Style + ToolCallError lipgloss.Style + ToolCallSuccess lipgloss.Style + ToolCallCancelled lipgloss.Style + EarlyStateMessage lipgloss.Style + + // Text selection + TextSelection lipgloss.Style + + // LSP and MCP status indicators + ItemOfflineIcon lipgloss.Style + ItemBusyIcon lipgloss.Style + ItemErrorIcon lipgloss.Style + ItemOnlineIcon 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 + + // Buttons + ButtonSelected lipgloss.Style + ButtonUnselected lipgloss.Style + + // Borders + BorderFocus lipgloss.Style + BorderBlur lipgloss.Style +} + +func DefaultStyles() Styles { + var ( + // 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 + 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 + ) + + s := Styles{} + + // borders + s.FocusedMessageBorder = lipgloss.Border{Left: BorderThick} + + // text presets + s.Base = lipgloss.NewStyle().Foreground(fgBase) + s.Muted = lipgloss.NewStyle().Foreground(fgMuted) + s.Subtle = lipgloss.NewStyle().Foreground(fgSubtle) + + s.WindowTooSmall = s.Muted + + // tag presets + s.TagBase = lipgloss.NewStyle().Padding(0, 1).Foreground(white) + s.TagError = s.TagBase.Background(redDark) + s.TagInfo = s.TagBase.Background(blueLight) + + // headers + s.HeaderTool = lipgloss.NewStyle().Foreground(blue) + s.HeaderToolNested = lipgloss.NewStyle().Foreground(fgHalfMuted) + + // panels + s.PanelMuted = s.Muted.Background(bgBaseLighter) + s.PanelBase = lipgloss.NewStyle().Background(bgBase) + + // code line number + s.LineNumber = lipgloss.NewStyle().Foreground(fgMuted).Background(bgBase).PaddingRight(1).PaddingLeft(1) + + // Tool calls + s.ToolCallPending = lipgloss.NewStyle().Foreground(greenDark).SetString(ToolPending) + s.ToolCallError = lipgloss.NewStyle().Foreground(redDark).SetString(ToolError) + s.ToolCallSuccess = lipgloss.NewStyle().Foreground(green).SetString(ToolSuccess) + // Cancelled uses muted tone but same glyph as pending + s.ToolCallCancelled = s.Muted.SetString(ToolPending) + s.EarlyStateMessage = s.Subtle.PaddingLeft(2) + + // Buttons + s.ButtonSelected = lipgloss.NewStyle().Foreground(white).Background(secondary) + s.ButtonUnselected = s.Base.Background(bgSubtle) + + // Borders + s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2) + + return s +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 2c2ce28e0d2a5f144a8025a43a6c9a01ae778c65..c09379203ae67bec3175d75de06b65df881c538d 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -6,7 +6,6 @@ import ( "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/app" - "github.com/charmbracelet/crush/internal/ui/dialog" "github.com/charmbracelet/lipgloss/v2" uv "github.com/charmbracelet/ultraviolet" ) @@ -17,29 +16,33 @@ const ( uiStateMain uiState = iota ) -type Model struct { - app *app.App +type UI struct { + app *app.App + com *Common + width, height int state uiState keyMap KeyMap + styles Styles - dialog *dialog.Overlay + dialog *Overlay } -func New(app *app.App) *Model { - return &Model{ +func New(com *Common, app *app.App) *UI { + return &UI{ app: app, - dialog: dialog.NewOverlay(), + com: com, + dialog: NewDialogOverlay(), keyMap: DefaultKeyMap(), } } -func (m *Model) Init() tea.Cmd { +func (m *UI) Init() tea.Cmd { return nil } -func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -50,7 +53,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case uiStateMain: switch { case key.Matches(msg, m.keyMap.Quit): - quitDialog := dialog.NewQuit() + quitDialog := NewQuitDialog(m.com) if !m.dialog.ContainsDialog(quitDialog.ID()) { m.dialog.AddDialog(quitDialog) return m, nil @@ -68,7 +71,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *Model) View() tea.View { +func (m *UI) View() tea.View { var v tea.View // The screen area we're working with From 8783795bcbeedfafd2b58b566a5589d8d6bb8f6f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 28 Oct 2025 15:40:58 -0400 Subject: [PATCH 003/335] feat(ui): restructure UI package into subpackages --- internal/cmd/root.go | 6 ++- internal/ui/{ => common}/common.go | 11 ++++-- internal/ui/{ => dialog}/dialog.go | 23 +++++------ .../ui/{quit_dialog.go => dialog/quit.go} | 25 ++++++------ internal/ui/model.go | 9 ----- internal/ui/{ => model}/keys.go | 2 +- internal/ui/{ => model}/ui.go | 39 +++++++++++++++---- internal/ui/{ => styles}/styles.go | 2 +- 8 files changed, 70 insertions(+), 47 deletions(-) rename internal/ui/{ => common}/common.go (57%) rename internal/ui/{ => dialog}/dialog.go (84%) rename internal/ui/{quit_dialog.go => dialog/quit.go} (85%) delete mode 100644 internal/ui/model.go rename internal/ui/{ => model}/keys.go (98%) rename internal/ui/{ => model}/ui.go (65%) rename internal/ui/{ => styles}/styles.go (99%) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index fbd86bd7592d4bd8a65820d2131fdb9873de8200..9678633d0a661af3a7311d93c0ca341178a25c57 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -18,7 +18,8 @@ import ( "github.com/charmbracelet/crush/internal/db" "github.com/charmbracelet/crush/internal/event" "github.com/charmbracelet/crush/internal/tui" - "github.com/charmbracelet/crush/internal/ui" + "github.com/charmbracelet/crush/internal/ui/common" + ui "github.com/charmbracelet/crush/internal/ui/model" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/fang" "github.com/charmbracelet/lipgloss/v2" @@ -83,7 +84,8 @@ crush -y // Set up the TUI. // ui := tui.New(app) - ui := ui.New(app) + com := common.DefaultCommon(app.Config()) + ui := ui.New(com, app) program := tea.NewProgram( ui, tea.WithContext(cmd.Context()), diff --git a/internal/ui/common.go b/internal/ui/common/common.go similarity index 57% rename from internal/ui/common.go rename to internal/ui/common/common.go index 72a0bae02667aad6a0b7028b2e1fcdd017549137..3daec59f791c41ec281f718ef7ea434beb8cd7e8 100644 --- a/internal/ui/common.go +++ b/internal/ui/common/common.go @@ -1,17 +1,20 @@ -package ui +package common -import "github.com/charmbracelet/crush/internal/config" +import ( + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/styles" +) // Common defines common UI options and configurations. type Common struct { Config *config.Config - Styles Styles + Styles styles.Styles } // DefaultCommon returns the default common UI configurations. func DefaultCommon(cfg *config.Config) *Common { return &Common{ Config: cfg, - Styles: DefaultStyles(), + Styles: styles.DefaultStyles(), } } diff --git a/internal/ui/dialog.go b/internal/ui/dialog/dialog.go similarity index 84% rename from internal/ui/dialog.go rename to internal/ui/dialog/dialog.go index bfae19d05b8f3fc53e7988c8c29ed2454ffb17a9..01815e24db73cb7e997c5fd935c0cfc822a2a9ac 100644 --- a/internal/ui/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -1,19 +1,20 @@ -package ui +package dialog import ( "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/ui/component" "github.com/charmbracelet/lipgloss/v2" ) -// DialogOverlayKeyMap defines key bindings for dialogs. -type DialogOverlayKeyMap struct { +// OverlayKeyMap defines key bindings for dialogs. +type OverlayKeyMap struct { Close key.Binding } -// DefaultDialogOverlayKeyMap returns the default key bindings for dialogs. -func DefaultDialogOverlayKeyMap() DialogOverlayKeyMap { - return DialogOverlayKeyMap{ +// DefaultOverlayKeyMap returns the default key bindings for dialogs. +func DefaultOverlayKeyMap() OverlayKeyMap { + return OverlayKeyMap{ Close: key.NewBinding( key.WithKeys("esc", "alt+esc"), ), @@ -22,21 +23,21 @@ func DefaultDialogOverlayKeyMap() DialogOverlayKeyMap { // Dialog is a component that can be displayed on top of the UI. type Dialog interface { - Model[Dialog] + component.Model[Dialog] ID() string } // Overlay manages multiple dialogs as an overlay. type Overlay struct { dialogs []Dialog - keyMap DialogOverlayKeyMap + keyMap OverlayKeyMap } -// NewDialogOverlay creates a new [Overlay] instance. -func NewDialogOverlay(dialogs ...Dialog) *Overlay { +// NewOverlay creates a new [Overlay] instance. +func NewOverlay(dialogs ...Dialog) *Overlay { return &Overlay{ dialogs: dialogs, - keyMap: DefaultDialogOverlayKeyMap(), + keyMap: DefaultOverlayKeyMap(), } } diff --git a/internal/ui/quit_dialog.go b/internal/ui/dialog/quit.go similarity index 85% rename from internal/ui/quit_dialog.go rename to internal/ui/dialog/quit.go index b099ba671f1df17ef7d96c19a94ec91d50d29d24..37d089cbcec94e9082c6d9b8ac126abab0c8642b 100644 --- a/internal/ui/quit_dialog.go +++ b/internal/ui/dialog/quit.go @@ -1,8 +1,9 @@ -package ui +package dialog import ( "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/lipgloss/v2" ) @@ -46,16 +47,16 @@ func DefaultQuitKeyMap() QuitDialogKeyMap { } } -// QuitDialog represents a confirmation dialog for quitting the application. -type QuitDialog struct { - com *Common +// Quit represents a confirmation dialog for quitting the application. +type Quit struct { + com *common.Common keyMap QuitDialogKeyMap selectedNo bool // true if "No" button is selected } -// NewQuitDialog creates a new quit confirmation dialog. -func NewQuitDialog(com *Common) *QuitDialog { - q := &QuitDialog{ +// NewQuit creates a new quit confirmation dialog. +func NewQuit(com *common.Common) *Quit { + q := &Quit{ com: com, keyMap: DefaultQuitKeyMap(), } @@ -63,12 +64,12 @@ func NewQuitDialog(com *Common) *QuitDialog { } // ID implements [Model]. -func (*QuitDialog) ID() string { +func (*Quit) ID() string { return "quit" } // Update implements [Model]. -func (q *QuitDialog) Update(msg tea.Msg) (Dialog, tea.Cmd) { +func (q *Quit) Update(msg tea.Msg) (Dialog, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch { @@ -91,7 +92,7 @@ func (q *QuitDialog) Update(msg tea.Msg) (Dialog, tea.Cmd) { } // View implements [Model]. -func (q *QuitDialog) View() string { +func (q *Quit) View() string { const question = "Are you sure you want to quit?" baseStyle := q.com.Styles.Base yesStyle := q.com.Styles.ButtonSelected @@ -125,7 +126,7 @@ func (q *QuitDialog) View() string { } // ShortHelp implements [help.KeyMap]. -func (q *QuitDialog) ShortHelp() []key.Binding { +func (q *Quit) ShortHelp() []key.Binding { return []key.Binding{ q.keyMap.LeftRight, q.keyMap.EnterSpace, @@ -133,7 +134,7 @@ func (q *QuitDialog) ShortHelp() []key.Binding { } // FullHelp implements [help.KeyMap]. -func (q *QuitDialog) FullHelp() [][]key.Binding { +func (q *Quit) FullHelp() [][]key.Binding { return [][]key.Binding{ {q.keyMap.LeftRight, q.keyMap.EnterSpace, q.keyMap.Yes, q.keyMap.No}, {q.keyMap.Tab, q.keyMap.Close}, diff --git a/internal/ui/model.go b/internal/ui/model.go deleted file mode 100644 index ec8876b9adb643530d732a6f92bab5d28a8c1938..0000000000000000000000000000000000000000 --- a/internal/ui/model.go +++ /dev/null @@ -1,9 +0,0 @@ -package ui - -import tea "github.com/charmbracelet/bubbletea/v2" - -// Model represents a common interface for UI components. -type Model[T any] interface { - Update(msg tea.Msg) (T, tea.Cmd) - View() string -} diff --git a/internal/ui/keys.go b/internal/ui/model/keys.go similarity index 98% rename from internal/ui/keys.go rename to internal/ui/model/keys.go index 335840fa06c7927588410c71140d3537881a434b..e0c1e0a6d96d0c3b624806146c80c5a668ef8aad 100644 --- a/internal/ui/keys.go +++ b/internal/ui/model/keys.go @@ -1,4 +1,4 @@ -package ui +package model import "github.com/charmbracelet/bubbles/v2/key" diff --git a/internal/ui/ui.go b/internal/ui/model/ui.go similarity index 65% rename from internal/ui/ui.go rename to internal/ui/model/ui.go index c09379203ae67bec3175d75de06b65df881c538d..fd5b8e16dc5cfb1bf7e8e281c36d4865b9b47d3f 100644 --- a/internal/ui/ui.go +++ b/internal/ui/model/ui.go @@ -1,4 +1,4 @@ -package ui +package model import ( "image" @@ -6,6 +6,8 @@ import ( "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/dialog" "github.com/charmbracelet/lipgloss/v2" uv "github.com/charmbracelet/ultraviolet" ) @@ -18,22 +20,21 @@ const ( type UI struct { app *app.App - com *Common + com *common.Common width, height int state uiState keyMap KeyMap - styles Styles - dialog *Overlay + dialog *dialog.Overlay } -func New(com *Common, app *app.App) *UI { +func New(com *common.Common, app *app.App) *UI { return &UI{ app: app, com: com, - dialog: NewDialogOverlay(), + dialog: dialog.NewOverlay(), keyMap: DefaultKeyMap(), } } @@ -53,7 +54,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case uiStateMain: switch { case key.Matches(msg, m.keyMap.Quit): - quitDialog := NewQuitDialog(m.com) + quitDialog := dialog.NewQuit(m.com) if !m.dialog.ContainsDialog(quitDialog.ID()) { m.dialog.AddDialog(quitDialog) return m, nil @@ -73,6 +74,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *UI) View() tea.View { var v tea.View + v.AltScreen = true // The screen area we're working with area := image.Rect(0, 0, m.width, m.height) @@ -88,6 +90,29 @@ func (m *UI) View() tea.View { ) } + mainRect, sideRect := uv.SplitHorizontal(area, uv.Fixed(area.Dx()-40)) + mainRect, footRect := uv.SplitVertical(mainRect, uv.Fixed(area.Dy()-7)) + + layers = append(layers, lipgloss.NewLayer( + lipgloss.NewStyle().Width(mainRect.Dx()). + Height(mainRect.Dy()). + Border(lipgloss.NormalBorder()). + Render(" Main View "), + ).X(mainRect.Min.X).Y(mainRect.Min.Y), + lipgloss.NewLayer( + lipgloss.NewStyle().Width(sideRect.Dx()). + Height(sideRect.Dy()). + Border(lipgloss.NormalBorder()). + Render(" Side View "), + ).X(sideRect.Min.X).Y(sideRect.Min.Y), + lipgloss.NewLayer( + lipgloss.NewStyle().Width(footRect.Dx()). + Height(footRect.Dy()). + Border(lipgloss.NormalBorder()). + Render(" Footer View "), + ).X(footRect.Min.X).Y(footRect.Min.Y), + ) + v.Layer = lipgloss.NewCanvas(layers...) return v diff --git a/internal/ui/styles.go b/internal/ui/styles/styles.go similarity index 99% rename from internal/ui/styles.go rename to internal/ui/styles/styles.go index 11a3871317b720f63b1819a1a7590e6282797e18..8a1dcc71d0a3462305dea97b04291a0be6a122f6 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles/styles.go @@ -1,4 +1,4 @@ -package ui +package styles import ( "github.com/charmbracelet/bubbles/v2/filepicker" From 7d9976252d289b3331376ff1e0aa3d597736d708 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 28 Oct 2025 17:56:29 -0400 Subject: [PATCH 004/335] feat(ui): add chat and editor models with keybindings --- internal/ui/common/common.go | 15 ++ internal/ui/common/interface.go | 11 + internal/ui/dialog/dialog.go | 9 +- internal/ui/dialog/quit.go | 5 +- internal/ui/model/chat.go | 86 ++++++++ internal/ui/model/editor.go | 201 ++++++++++++++++++ internal/ui/model/keys.go | 5 + internal/ui/model/ui.go | 217 ++++++++++++++----- internal/ui/styles/styles.go | 361 +++++++++++++++++++++++++++++++- 9 files changed, 855 insertions(+), 55 deletions(-) create mode 100644 internal/ui/common/interface.go create mode 100644 internal/ui/model/chat.go create mode 100644 internal/ui/model/editor.go diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 3daec59f791c41ec281f718ef7ea434beb8cd7e8..88ca494ad5ac885806fe0d8959ae8b5e4b5592f9 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -1,8 +1,11 @@ package common import ( + "image" + "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" ) // Common defines common UI options and configurations. @@ -18,3 +21,15 @@ func DefaultCommon(cfg *config.Config) *Common { Styles: styles.DefaultStyles(), } } + +// CenterRect returns a new [Rectangle] centered within the given area with the +// specified width and height. +func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle { + centerX := area.Min.X + area.Dx()/2 + centerY := area.Min.Y + area.Dy()/2 + minX := centerX - width/2 + minY := centerY - height/2 + maxX := minX + width + maxY := minY + height + return image.Rect(minX, minY, maxX, maxY) +} diff --git a/internal/ui/common/interface.go b/internal/ui/common/interface.go new file mode 100644 index 0000000000000000000000000000000000000000..aee3a1f081b965fbccb2f98e6d83846c122d3e5c --- /dev/null +++ b/internal/ui/common/interface.go @@ -0,0 +1,11 @@ +package common + +import ( + tea "github.com/charmbracelet/bubbletea/v2" +) + +// Model represents a common interface for UI components. +type Model[T any] interface { + Update(msg tea.Msg) (T, tea.Cmd) + View() string +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 01815e24db73cb7e997c5fd935c0cfc822a2a9ac..f9fcf6d89a9b7168315f42bb53dabecfc63f7647 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -3,7 +3,7 @@ package dialog import ( "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/ui/component" + "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/lipgloss/v2" ) @@ -23,7 +23,7 @@ func DefaultOverlayKeyMap() OverlayKeyMap { // Dialog is a component that can be displayed on top of the UI. type Dialog interface { - component.Model[Dialog] + common.Model[Dialog] ID() string } @@ -41,6 +41,11 @@ func NewOverlay(dialogs ...Dialog) *Overlay { } } +// HasDialogs checks if there are any active dialogs. +func (d *Overlay) HasDialogs() bool { + return len(d.dialogs) > 0 +} + // ContainsDialog checks if a dialog with the specified ID exists. func (d *Overlay) ContainsDialog(dialogID string) bool { for _, dialog := range d.dialogs { diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go index 37d089cbcec94e9082c6d9b8ac126abab0c8642b..79c5d219360279c2758fcea227cbc3c409f2be0f 100644 --- a/internal/ui/dialog/quit.go +++ b/internal/ui/dialog/quit.go @@ -7,6 +7,9 @@ import ( "github.com/charmbracelet/lipgloss/v2" ) +// QuitDialogID is the identifier for the quit dialog. +const QuitDialogID = "quit" + // QuitDialogKeyMap represents key bindings for the quit dialog. type QuitDialogKeyMap struct { LeftRight, @@ -65,7 +68,7 @@ func NewQuit(com *common.Common) *Quit { // ID implements [Model]. func (*Quit) ID() string { - return "quit" + return QuitDialogID } // Update implements [Model]. diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go new file mode 100644 index 0000000000000000000000000000000000000000..f19c2cac85eee946225e0041013a978b53a76f15 --- /dev/null +++ b/internal/ui/model/chat.go @@ -0,0 +1,86 @@ +package model + +import ( + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/ui/common" +) + +// ChatKeyMap defines key bindings for the chat model. +type ChatKeyMap struct { + NewSession key.Binding + AddAttachment key.Binding + Cancel key.Binding + Tab key.Binding + Details key.Binding +} + +// DefaultChatKeyMap returns the default key bindings for the chat model. +func DefaultChatKeyMap() ChatKeyMap { + return ChatKeyMap{ + 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"), + ), + } +} + +// ChatModel represents the chat UI model. +type ChatModel struct { + app *app.App + com *common.Common + + keyMap ChatKeyMap +} + +// NewChatModel creates a new instance of ChatModel. +func NewChatModel(com *common.Common, app *app.App) *ChatModel { + return &ChatModel{ + app: app, + com: com, + keyMap: DefaultChatKeyMap(), + } +} + +// Init initializes the chat model. +func (m *ChatModel) Init() tea.Cmd { + return nil +} + +// Update handles incoming messages and updates the chat model state. +func (m *ChatModel) Update(msg tea.Msg) (*ChatModel, tea.Cmd) { + // Handle messages here + return m, nil +} + +// View renders the chat model's view. +func (m *ChatModel) View() string { + return "Chat Model View" +} + +// ShortHelp returns a brief help view for the chat model. +func (m *ChatModel) ShortHelp() []key.Binding { + return nil +} + +// FullHelp returns a detailed help view for the chat model. +func (m *ChatModel) FullHelp() [][]key.Binding { + return nil +} diff --git a/internal/ui/model/editor.go b/internal/ui/model/editor.go new file mode 100644 index 0000000000000000000000000000000000000000..384d9638a8ed34455df4ed50fe7c50227a530525 --- /dev/null +++ b/internal/ui/model/editor.go @@ -0,0 +1,201 @@ +package model + +import ( + "math/rand" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/textarea" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/ui/common" +) + +type EditorKeyMap struct { + AddFile key.Binding + SendMessage key.Binding + OpenEditor key.Binding + Newline 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"), + ), + } +} + +// EditorModel represents the editor UI model. +type EditorModel struct { + com *common.Common + app *app.App + + keyMap EditorKeyMap + textarea *textarea.Model + + readyPlaceholder string + workingPlaceholder string +} + +// NewEditorModel creates a new instance of EditorModel. +func NewEditorModel(com *common.Common, app *app.App) *EditorModel { + ta := textarea.New() + ta.SetStyles(com.Styles.TextArea) + ta.ShowLineNumbers = false + ta.CharLimit = -1 + ta.SetVirtualCursor(false) + ta.Focus() + e := &EditorModel{ + com: com, + app: app, + keyMap: DefaultEditorKeyMap(), + textarea: ta, + } + + e.setEditorPrompt() + e.randomizePlaceholders() + e.textarea.Placeholder = e.readyPlaceholder + + return e +} + +// Init initializes the editor model. +func (m *EditorModel) Init() tea.Cmd { + return nil +} + +// Update handles updates to the editor model. +func (m *EditorModel) Update(msg tea.Msg) (*EditorModel, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + m.textarea, cmd = m.textarea.Update(msg) + cmds = append(cmds, cmd) + + // Textarea placeholder logic + 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!" + } + + // TODO: Add attachments + + return m, tea.Batch(cmds...) +} + +// View renders the editor model. +func (m *EditorModel) View() string { + return m.textarea.View() +} + +// ShortHelp returns the short help view for the editor model. +func (m *EditorModel) ShortHelp() []key.Binding { + return nil +} + +// FullHelp returns the full help view for the editor model. +func (m *EditorModel) FullHelp() [][]key.Binding { + return nil +} + +// Cursor returns the relative cursor position of the editor. +func (m *EditorModel) Cursor() *tea.Cursor { + return m.textarea.Cursor() +} + +// Blur implements Container. +func (c *EditorModel) Blur() tea.Cmd { + c.textarea.Blur() + return nil +} + +// Focus implements Container. +func (c *EditorModel) Focus() tea.Cmd { + return c.textarea.Focus() +} + +// Focused returns whether the editor is focused. +func (c *EditorModel) Focused() bool { + return c.textarea.Focused() +} + +// SetSize sets the size of the editor. +func (m *EditorModel) SetSize(width, height int) { + m.textarea.SetWidth(width) + m.textarea.SetHeight(height) +} + +func (m *EditorModel) setEditorPrompt() { + if m.app.Permissions.SkipRequests() { + m.textarea.SetPromptFunc(4, m.yoloPromptFunc) + return + } + m.textarea.SetPromptFunc(4, m.normalPromptFunc) +} + +func (m *EditorModel) normalPromptFunc(info textarea.PromptInfo) string { + t := m.com.Styles + if info.LineNumber == 0 { + return " > " + } + if info.Focused { + return t.EditorPromptNormalFocused.Render() + } + return t.EditorPromptNormalBlurred.Render() +} + +func (m *EditorModel) yoloPromptFunc(info textarea.PromptInfo) string { + t := m.com.Styles + if info.LineNumber == 0 { + if info.Focused { + return t.EditorPromptYoloIconFocused.Render() + } else { + return t.EditorPromptYoloIconBlurred.Render() + } + } + if info.Focused { + return t.EditorPromptYoloDotsFocused.Render() + } + return t.EditorPromptYoloDotsBlurred.Render() +} + +var readyPlaceholders = [...]string{ + "Ready!", + "Ready...", + "Ready?", + "Ready for instructions", +} + +var workingPlaceholders = [...]string{ + "Working!", + "Working...", + "Brrrrr...", + "Prrrrrrrr...", + "Processing...", + "Thinking...", +} + +func (m *EditorModel) randomizePlaceholders() { + m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] + m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] +} diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index e0c1e0a6d96d0c3b624806146c80c5a668ef8aad..62b22544c5bd0964f6e1a978fa79be680a29366b 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -8,6 +8,7 @@ type KeyMap struct { Commands key.Binding Suspend key.Binding Sessions key.Binding + Tab key.Binding } func DefaultKeyMap() KeyMap { @@ -32,5 +33,9 @@ func DefaultKeyMap() KeyMap { key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "sessions"), ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "change focus"), + ), } } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index fd5b8e16dc5cfb1bf7e8e281c36d4865b9b47d3f..20f1847cdf917eef2c9e1959dde067b3224da8cc 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2,7 +2,9 @@ package model import ( "image" + "math/rand" + "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/app" @@ -15,19 +17,25 @@ import ( type uiState uint8 const ( - uiStateMain uiState = iota + uiChat uiState = iota + uiEdit ) type UI struct { app *app.App com *common.Common - width, height int - state uiState + state uiState + showFullHelp bool keyMap KeyMap + chat *ChatModel + editor *EditorModel dialog *dialog.Overlay + help help.Model + + layout layout } func New(com *common.Common, app *app.App) *UI { @@ -36,6 +44,8 @@ func New(com *common.Common, app *app.App) *UI { com: com, dialog: dialog.NewOverlay(), keyMap: DefaultKeyMap(), + editor: NewEditorModel(com, app), + help: help.New(), } } @@ -47,28 +57,35 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height + m.updateLayout(msg.Width, msg.Height) + m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy()) case tea.KeyPressMsg: - switch m.state { - case uiStateMain: + if m.dialog.HasDialogs() { + m.updateDialogs(msg, &cmds) + } else { switch { + case key.Matches(msg, m.keyMap.Tab): + if m.state == uiChat { + m.state = uiEdit + cmds = append(cmds, m.editor.Focus()) + } else { + m.state = uiChat + cmds = append(cmds, m.editor.Blur()) + } + case key.Matches(msg, m.keyMap.Help): + m.showFullHelp = !m.showFullHelp + m.help.ShowAll = m.showFullHelp case key.Matches(msg, m.keyMap.Quit): - quitDialog := dialog.NewQuit(m.com) - if !m.dialog.ContainsDialog(quitDialog.ID()) { - m.dialog.AddDialog(quitDialog) + if !m.dialog.ContainsDialog(dialog.QuitDialogID) { + m.dialog.AddDialog(dialog.NewQuit(m.com)) return m, nil } + default: + m.updateFocused(msg, &cmds) } } } - updatedDialog, cmd := m.dialog.Update(msg) - m.dialog = updatedDialog - if cmd != nil { - cmds = append(cmds, cmd) - } - return m, tea.Batch(cmds...) } @@ -76,41 +93,63 @@ func (m *UI) View() tea.View { var v tea.View v.AltScreen = true - // The screen area we're working with - area := image.Rect(0, 0, m.width, m.height) layers := []*lipgloss.Layer{} - if dialogView := m.dialog.View(); dialogView != "" { - dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView) - dialogArea := centerRect(area, dialogWidth, dialogHeight) - layers = append(layers, - lipgloss.NewLayer(dialogView). - X(dialogArea.Min.X). - Y(dialogArea.Min.Y), - ) + // Determine the help key map based on focus + helpKeyMap := m.focusedKeyMap() + + // The screen areas we're working with + area := m.layout.area + chatRect := m.layout.chat + sideRect := m.layout.sidebar + editRect := m.layout.editor + helpRect := m.layout.help + + if m.dialog.HasDialogs() { + if dialogView := m.dialog.View(); dialogView != "" { + // If the dialog has its own help, use that instead + if len(m.dialog.FullHelp()) > 0 || len(m.dialog.ShortHelp()) > 0 { + helpKeyMap = m.dialog + } + + dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView) + dialogArea := common.CenterRect(area, dialogWidth, dialogHeight) + layers = append(layers, + lipgloss.NewLayer(dialogView). + X(dialogArea.Min.X). + Y(dialogArea.Min.Y). + Z(99), + ) + } } - mainRect, sideRect := uv.SplitHorizontal(area, uv.Fixed(area.Dx()-40)) - mainRect, footRect := uv.SplitVertical(mainRect, uv.Fixed(area.Dy()-7)) + if m.state == uiEdit && m.editor.Focused() { + cur := m.editor.Cursor() + cur.X++ // Adjust for app margins + cur.Y += editRect.Min.Y + v.Cursor = cur + } layers = append(layers, lipgloss.NewLayer( - lipgloss.NewStyle().Width(mainRect.Dx()). - Height(mainRect.Dy()). - Border(lipgloss.NormalBorder()). + lipgloss.NewStyle().Width(chatRect.Dx()). + Height(chatRect.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). Render(" Main View "), - ).X(mainRect.Min.X).Y(mainRect.Min.Y), + ).X(chatRect.Min.X).Y(chatRect.Min.Y), lipgloss.NewLayer( lipgloss.NewStyle().Width(sideRect.Dx()). Height(sideRect.Dy()). - Border(lipgloss.NormalBorder()). + Background(lipgloss.ANSIColor(rand.Intn(256))). Render(" Side View "), ).X(sideRect.Min.X).Y(sideRect.Min.Y), + lipgloss.NewLayer(m.editor.View()). + X(editRect.Min.X).Y(editRect.Min.Y), lipgloss.NewLayer( - lipgloss.NewStyle().Width(footRect.Dx()). - Height(footRect.Dy()). - Border(lipgloss.NormalBorder()). - Render(" Footer View "), - ).X(footRect.Min.X).Y(footRect.Min.Y), + lipgloss.NewStyle().Width(helpRect.Dx()). + Height(helpRect.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(m.help.View(helpKeyMap)), + ).X(helpRect.Min.X).Y(helpRect.Min.Y), ) v.Layer = lipgloss.NewCanvas(layers...) @@ -118,14 +157,96 @@ func (m *UI) View() tea.View { return v } -// centerRect returns a new [Rectangle] centered within the given area with the -// specified width and height. -func centerRect(area uv.Rectangle, width, height int) uv.Rectangle { - centerX := area.Min.X + area.Dx()/2 - centerY := area.Min.Y + area.Dy()/2 - minX := centerX - width/2 - minY := centerY - height/2 - maxX := minX + width - maxY := minY + height - return image.Rect(minX, minY, maxX, maxY) +func (m *UI) focusedKeyMap() help.KeyMap { + if m.state == uiChat { + return m.chat + } + return m.editor +} + +// updateDialogs updates the dialog overlay with the given message and appends +// any resulting commands to the cmds slice. +func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { + updatedDialog, cmd := m.dialog.Update(msg) + m.dialog = updatedDialog + if cmd != nil { + *cmds = append(*cmds, cmd) + } +} + +// updateFocused updates the focused model (chat or editor) with the given message +// and appends any resulting commands to the cmds slice. +func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { + switch m.state { + case uiChat: + m.updateChat(msg, cmds) + case uiEdit: + m.updateEditor(msg, cmds) + } +} + +// updateChat updates the chat model with the given message and appends any +// resulting commands to the cmds slice. +func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { + updatedChat, cmd := m.chat.Update(msg) + m.chat = updatedChat + if cmd != nil { + *cmds = append(*cmds, cmd) + } +} + +// updateEditor updates the editor model with the given message and appends any +// resulting commands to the cmds slice. +func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { + updatedEditor, cmd := m.editor.Update(msg) + m.editor = updatedEditor + if cmd != nil { + *cmds = append(*cmds, cmd) + } +} + +// updateLayout updates the layout based on the given terminal width and +// height given in cells. +func (m *UI) updateLayout(w, h int) { + // The screen area we're working with + area := image.Rect(1, 1, w-1, h-1) // -1 for margins + helpKeyMap := m.focusedKeyMap() + helpHeight := 1 + if m.showFullHelp { + helpHeight = max(1, len(helpKeyMap.FullHelp())) + } + + chatRect, sideRect := uv.SplitHorizontal(area, uv.Fixed(area.Dx()-40)) + chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(area.Dy()-5-helpHeight)) + // Add 1 line margin bottom of mainRect + chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1)) + editRect, helpRect := uv.SplitVertical(editRect, uv.Fixed(5)) + // Add 1 line margin bottom of footRect + editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1)) + + m.layout = layout{ + area: area, + chat: chatRect, + editor: editRect, + sidebar: sideRect, + help: helpRect, + } +} + +// layout defines the positioning of UI elements. +type layout struct { + // area is the overall available area. + area uv.Rectangle + + // chat is the area for the chat pane. + chat uv.Rectangle + + // editor is the area for the editor pane. + editor uv.Rectangle + + // sidebar is the area for the sidebar. + sidebar uv.Rectangle + + // help is the area for the help view. + help uv.Rectangle } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 8a1dcc71d0a3462305dea97b04291a0be6a122f6..297cadfe721432a40055b31c2817d7ecbe3237b8 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -5,6 +5,7 @@ import ( "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/textarea" "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/tui/exp/diffview" "github.com/charmbracelet/glamour/v2/ansi" "github.com/charmbracelet/lipgloss/v2" @@ -30,6 +31,11 @@ const ( BorderThick string = "▌" ) +const ( + defaultMargin = 2 + defaultListIndent = 2 +) + type Styles struct { WindowTooSmall lipgloss.Style @@ -96,20 +102,28 @@ type Styles struct { // Borders BorderFocus lipgloss.Style BorderBlur lipgloss.Style + + // Editor + EditorPromptNormalFocused lipgloss.Style + EditorPromptNormalBlurred lipgloss.Style + EditorPromptYoloIconFocused lipgloss.Style + EditorPromptYoloIconBlurred lipgloss.Style + EditorPromptYoloDotsFocused lipgloss.Style + EditorPromptYoloDotsBlurred lipgloss.Style } func DefaultStyles() Styles { var ( - // primary = charmtone.Charple + primary = charmtone.Charple secondary = charmtone.Dolly - // tertiary = charmtone.Bok + tertiary = charmtone.Bok // accent = charmtone.Zest // Backgrounds bgBase = charmtone.Pepper bgBaseLighter = charmtone.BBQ bgSubtle = charmtone.Charcoal - // bgOverlay = charmtone.Iron + bgOverlay = charmtone.Iron // Foregrounds fgBase = charmtone.Ash @@ -119,7 +133,7 @@ func DefaultStyles() Styles { // fgSelected = charmtone.Salt // Borders - // border = charmtone.Charcoal + border = charmtone.Charcoal borderFocus = charmtone.Charple // Status @@ -147,8 +161,334 @@ func DefaultStyles() Styles { // cherry = charmtone.Cherry ) + base := lipgloss.NewStyle().Foreground(fgBase) + s := Styles{} + s.TextInput = textinput.Styles{ + Focused: textinput.StyleState{ + Text: base, + Placeholder: base.Foreground(fgSubtle), + Prompt: base.Foreground(tertiary), + Suggestion: base.Foreground(fgSubtle), + }, + Blurred: textinput.StyleState{ + Text: base.Foreground(fgMuted), + Placeholder: base.Foreground(fgSubtle), + Prompt: base.Foreground(fgMuted), + Suggestion: base.Foreground(fgSubtle), + }, + Cursor: textinput.CursorStyle{ + Color: secondary, + Shape: tea.CursorBar, + Blink: true, + }, + } + + s.TextArea = textarea.Styles{ + Focused: textarea.StyleState{ + Base: base, + Text: base, + LineNumber: base.Foreground(fgSubtle), + CursorLine: base, + CursorLineNumber: base.Foreground(fgSubtle), + Placeholder: base.Foreground(fgSubtle), + Prompt: base.Foreground(tertiary), + }, + Blurred: textarea.StyleState{ + Base: base, + Text: base.Foreground(fgMuted), + LineNumber: base.Foreground(fgMuted), + CursorLine: base, + CursorLineNumber: base.Foreground(fgMuted), + Placeholder: base.Foreground(fgSubtle), + Prompt: base.Foreground(fgMuted), + }, + Cursor: textarea.CursorStyle{ + Color: secondary, + Shape: tea.CursorBar, + Blink: true, + }, + } + + s.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 ", + }, + } + + s.Help = help.Styles{ + ShortKey: base.Foreground(fgMuted), + ShortDesc: base.Foreground(fgSubtle), + ShortSeparator: base.Foreground(border), + Ellipsis: base.Foreground(border), + FullKey: base.Foreground(fgMuted), + FullDesc: base.Foreground(fgSubtle), + FullSeparator: base.Foreground(border), + } + + s.Diff = diffview.Style{ + DividerLine: diffview.LineStyle{ + LineNumber: lipgloss.NewStyle(). + Foreground(fgHalfMuted). + Background(bgBaseLighter), + Code: lipgloss.NewStyle(). + Foreground(fgHalfMuted). + Background(bgBaseLighter), + }, + MissingLine: diffview.LineStyle{ + LineNumber: lipgloss.NewStyle(). + Background(bgBaseLighter), + Code: lipgloss.NewStyle(). + Background(bgBaseLighter), + }, + EqualLine: diffview.LineStyle{ + LineNumber: lipgloss.NewStyle(). + Foreground(fgMuted). + Background(bgBase), + Code: lipgloss.NewStyle(). + Foreground(fgMuted). + Background(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")), + }, + } + + s.FilePicker = filepicker.Styles{ + DisabledCursor: base.Foreground(fgMuted), + Cursor: base.Foreground(fgBase), + Symlink: base.Foreground(fgSubtle), + Directory: base.Foreground(primary), + File: base.Foreground(fgBase), + DisabledFile: base.Foreground(fgMuted), + DisabledSelected: base.Background(bgOverlay).Foreground(fgMuted), + Permission: base.Foreground(fgMuted), + Selected: base.Background(primary).Foreground(fgBase), + FileSize: base.Foreground(fgMuted), + EmptyDirectory: base.Foreground(fgMuted).PaddingLeft(2).SetString("Empty directory"), + } + // borders s.FocusedMessageBorder = lipgloss.Border{Left: BorderThick} @@ -190,5 +530,18 @@ func DefaultStyles() Styles { // Borders s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2) + // Editor + s.EditorPromptNormalFocused = lipgloss.NewStyle().Foreground(greenDark).SetString("::: ") + s.EditorPromptNormalBlurred = s.EditorPromptNormalFocused.Foreground(fgMuted) + s.EditorPromptYoloIconFocused = lipgloss.NewStyle().Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ") + s.EditorPromptYoloIconBlurred = s.EditorPromptYoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid) + s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::") + s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid) + return s } + +// 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 } From 1c968b8804acb3b5fe30fa1c1d4bfe13ce48fcc4 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 28 Oct 2025 17:57:19 -0400 Subject: [PATCH 005/335] fix(ui): lint: use consistent receiver names in EditorModel methods --- internal/ui/model/editor.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/ui/model/editor.go b/internal/ui/model/editor.go index 384d9638a8ed34455df4ed50fe7c50227a530525..1066de5f96a4c3105329d35e6434a768d7001ab0 100644 --- a/internal/ui/model/editor.go +++ b/internal/ui/model/editor.go @@ -124,19 +124,19 @@ func (m *EditorModel) Cursor() *tea.Cursor { } // Blur implements Container. -func (c *EditorModel) Blur() tea.Cmd { - c.textarea.Blur() +func (m *EditorModel) Blur() tea.Cmd { + m.textarea.Blur() return nil } // Focus implements Container. -func (c *EditorModel) Focus() tea.Cmd { - return c.textarea.Focus() +func (m *EditorModel) Focus() tea.Cmd { + return m.textarea.Focus() } // Focused returns whether the editor is focused. -func (c *EditorModel) Focused() bool { - return c.textarea.Focused() +func (m *EditorModel) Focused() bool { + return m.textarea.Focused() } // SetSize sets the size of the editor. From 6b8ca6a7c512daea687c8389657db2a421707c31 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 28 Oct 2025 18:34:47 -0400 Subject: [PATCH 006/335] fix(ui): accurately calculate help and main height in UI layout --- internal/ui/model/editor.go | 41 +++++++++++++++++++- internal/ui/model/ui.go | 76 +++++++++++++++++++++++-------------- 2 files changed, 87 insertions(+), 30 deletions(-) diff --git a/internal/ui/model/editor.go b/internal/ui/model/editor.go index 1066de5f96a4c3105329d35e6434a768d7001ab0..b4c8644c7d8264216305c99b4f8df066e813b400 100644 --- a/internal/ui/model/editor.go +++ b/internal/ui/model/editor.go @@ -15,6 +15,11 @@ type EditorKeyMap struct { SendMessage key.Binding OpenEditor key.Binding Newline key.Binding + + // Attachments key maps + AttachmentDeleteMode key.Binding + Escape key.Binding + DeleteAllAttachments key.Binding } func DefaultEditorKeyMap() EditorKeyMap { @@ -38,6 +43,18 @@ func DefaultEditorKeyMap() EditorKeyMap { // to reflect that. key.WithHelp("ctrl+j", "newline"), ), + 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"), + ), } } @@ -49,6 +66,8 @@ type EditorModel struct { keyMap EditorKeyMap textarea *textarea.Model + attachments []any // TODO: Implement attachments + readyPlaceholder string workingPlaceholder string } @@ -110,12 +129,30 @@ func (m *EditorModel) View() string { // ShortHelp returns the short help view for the editor model. func (m *EditorModel) ShortHelp() []key.Binding { - return nil + k := m.keyMap + binds := []key.Binding{ + k.AddFile, + k.SendMessage, + k.OpenEditor, + k.Newline, + } + + if len(m.attachments) > 0 { + binds = append(binds, + k.AttachmentDeleteMode, + k.DeleteAllAttachments, + k.Escape, + ) + } + + return binds } // FullHelp returns the full help view for the editor model. func (m *EditorModel) FullHelp() [][]key.Binding { - return nil + return [][]key.Binding{ + m.ShortHelp(), + } } // Cursor returns the relative cursor position of the editor. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 20f1847cdf917eef2c9e1959dde067b3224da8cc..f157efce1df8091a9b647bef6172ba47e4095098 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -59,6 +59,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.updateLayout(msg.Width, msg.Height) m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy()) + m.help.Width = m.layout.help.Dx() case tea.KeyPressMsg: if m.dialog.HasDialogs() { m.updateDialogs(msg, &cmds) @@ -75,6 +76,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keyMap.Help): m.showFullHelp = !m.showFullHelp m.help.ShowAll = m.showFullHelp + m.updateLayout(m.layout.area.Dx(), m.layout.area.Dy()) case key.Matches(msg, m.keyMap.Quit): if !m.dialog.ContainsDialog(dialog.QuitDialogID) { m.dialog.AddDialog(dialog.NewQuit(m.com)) @@ -130,27 +132,28 @@ func (m *UI) View() tea.View { v.Cursor = cur } - layers = append(layers, lipgloss.NewLayer( - lipgloss.NewStyle().Width(chatRect.Dx()). - Height(chatRect.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Main View "), - ).X(chatRect.Min.X).Y(chatRect.Min.Y), - lipgloss.NewLayer( - lipgloss.NewStyle().Width(sideRect.Dx()). - Height(sideRect.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Side View "), - ).X(sideRect.Min.X).Y(sideRect.Min.Y), - lipgloss.NewLayer(m.editor.View()). - X(editRect.Min.X).Y(editRect.Min.Y), - lipgloss.NewLayer( - lipgloss.NewStyle().Width(helpRect.Dx()). - Height(helpRect.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(m.help.View(helpKeyMap)), - ).X(helpRect.Min.X).Y(helpRect.Min.Y), - ) + mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y). + Width(area.Dx()).Height(area.Dy()). + AddLayers( + lipgloss.NewLayer( + lipgloss.NewStyle().Width(chatRect.Dx()). + Height(chatRect.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Main View "), + ).X(chatRect.Min.X).Y(chatRect.Min.Y), + lipgloss.NewLayer( + lipgloss.NewStyle().Width(sideRect.Dx()). + Height(sideRect.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Side View "), + ).X(sideRect.Min.X).Y(sideRect.Min.Y), + lipgloss.NewLayer(m.editor.View()). + X(editRect.Min.X).Y(editRect.Min.Y), + lipgloss.NewLayer(m.help.View(helpKeyMap)). + X(helpRect.Min.X).Y(helpRect.Min.Y), + ) + + layers = append(layers, mainLayer) v.Layer = lipgloss.NewCanvas(layers...) @@ -209,23 +212,37 @@ func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { // height given in cells. func (m *UI) updateLayout(w, h int) { // The screen area we're working with - area := image.Rect(1, 1, w-1, h-1) // -1 for margins + area := image.Rect(0, 0, w, h) helpKeyMap := m.focusedKeyMap() helpHeight := 1 + if m.dialog.HasDialogs() && len(m.dialog.FullHelp()) > 0 && len(m.dialog.ShortHelp()) > 0 { + helpKeyMap = m.dialog + } if m.showFullHelp { - helpHeight = max(1, len(helpKeyMap.FullHelp())) + for _, row := range helpKeyMap.FullHelp() { + helpHeight = max(helpHeight, len(row)) + } } - chatRect, sideRect := uv.SplitHorizontal(area, uv.Fixed(area.Dx()-40)) - chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(area.Dy()-5-helpHeight)) - // Add 1 line margin bottom of mainRect + // Add app margins + mainRect := area + mainRect.Min.X += 1 + mainRect.Min.Y += 1 + mainRect.Max.X -= 1 + mainRect.Max.Y -= 1 + + mainRect, helpRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-helpHeight)) + chatRect, sideRect := uv.SplitHorizontal(mainRect, uv.Fixed(mainRect.Dx()-40)) + chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(mainRect.Dy()-5)) + + // Add 1 line margin bottom of chatRect chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1)) - editRect, helpRect := uv.SplitVertical(editRect, uv.Fixed(5)) - // Add 1 line margin bottom of footRect + // Add 1 line margin bottom of editRect editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1)) m.layout = layout{ area: area, + main: mainRect, chat: chatRect, editor: editRect, sidebar: sideRect, @@ -238,6 +255,9 @@ type layout struct { // area is the overall available area. area uv.Rectangle + // main is the main area excluding help. + main uv.Rectangle + // chat is the area for the chat pane. chat uv.Rectangle From aec65c42f57648ab2d2715fe49851f09647d6271 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 28 Oct 2025 18:39:03 -0400 Subject: [PATCH 007/335] fix(ui): don't use separate showFullHelp field --- internal/ui/model/ui.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index f157efce1df8091a9b647bef6172ba47e4095098..eec74799774e91b132123bb3e467368ab98de103 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -25,8 +25,7 @@ type UI struct { app *app.App com *common.Common - state uiState - showFullHelp bool + state uiState keyMap KeyMap @@ -74,8 +73,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.editor.Blur()) } case key.Matches(msg, m.keyMap.Help): - m.showFullHelp = !m.showFullHelp - m.help.ShowAll = m.showFullHelp + m.help.ShowAll = !m.help.ShowAll m.updateLayout(m.layout.area.Dx(), m.layout.area.Dy()) case key.Matches(msg, m.keyMap.Quit): if !m.dialog.ContainsDialog(dialog.QuitDialogID) { @@ -218,7 +216,7 @@ func (m *UI) updateLayout(w, h int) { if m.dialog.HasDialogs() && len(m.dialog.FullHelp()) > 0 && len(m.dialog.ShortHelp()) > 0 { helpKeyMap = m.dialog } - if m.showFullHelp { + if m.help.ShowAll { for _, row := range helpKeyMap.FullHelp() { helpHeight = max(helpHeight, len(row)) } From 8d5226d29f3929dcbd7c8089e5995c068706fe2f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 28 Oct 2025 18:39:56 -0400 Subject: [PATCH 008/335] chore(ui): add comments to internal/ui/model/ui.go --- internal/ui/model/ui.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index eec74799774e91b132123bb3e467368ab98de103..d8ada8b41f8d5876439606badc88c2d0f72b42fe 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -14,13 +14,16 @@ import ( uv "github.com/charmbracelet/ultraviolet" ) +// uiState represents the current focus state of the UI. type uiState uint8 +// Possible uiState values. const ( uiChat uiState = iota uiEdit ) +// UI represents the main user interface model. type UI struct { app *app.App com *common.Common @@ -37,6 +40,7 @@ type UI struct { layout layout } +// New creates a new instance of the [UI] model. func New(com *common.Common, app *app.App) *UI { return &UI{ app: app, @@ -48,10 +52,12 @@ func New(com *common.Common, app *app.App) *UI { } } +// Init initializes the UI model. func (m *UI) Init() tea.Cmd { return nil } +// Update handles updates to the UI model. func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { @@ -89,6 +95,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +// View renders the UI model's view. func (m *UI) View() tea.View { var v tea.View v.AltScreen = true From 470e6f6de7b40957e080a17f25b879c4296eb329 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 3 Nov 2025 09:44:01 -0500 Subject: [PATCH 009/335] feat(ui): add initial sidebar component with logo --- internal/ui/common/common.go | 5 +- internal/ui/logo/logo.go | 346 +++++++++++++++++++++++++++++++++++ internal/ui/logo/rand.go | 24 +++ internal/ui/model/sidebar.go | 81 ++++++++ internal/ui/model/ui.go | 27 +-- internal/ui/styles/styles.go | 24 +++ 6 files changed, 492 insertions(+), 15 deletions(-) create mode 100644 internal/ui/logo/logo.go create mode 100644 internal/ui/logo/rand.go create mode 100644 internal/ui/model/sidebar.go diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 88ca494ad5ac885806fe0d8959ae8b5e4b5592f9..3583160faa40a2a2f507f4d56376fc619cb45d09 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -11,14 +11,15 @@ import ( // Common defines common UI options and configurations. type Common struct { Config *config.Config - Styles styles.Styles + Styles *styles.Styles } // DefaultCommon returns the default common UI configurations. func DefaultCommon(cfg *config.Config) *Common { + s := styles.DefaultStyles() return &Common{ Config: cfg, - Styles: styles.DefaultStyles(), + Styles: &s, } } diff --git a/internal/ui/logo/logo.go b/internal/ui/logo/logo.go new file mode 100644 index 0000000000000000000000000000000000000000..6d1fbe5c69b908c27f3819011a02054d9b940452 --- /dev/null +++ b/internal/ui/logo/logo.go @@ -0,0 +1,346 @@ +// Package logo renders a Crush wordmark in a stylized way. +package logo + +import ( + "fmt" + "image/color" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" + "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/ui/logo/rand.go b/internal/ui/logo/rand.go new file mode 100644 index 0000000000000000000000000000000000000000..cf79487e23825b468c98a0f27bbc8dbfbb1a7081 --- /dev/null +++ b/internal/ui/logo/rand.go @@ -0,0 +1,24 @@ +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/ui/model/sidebar.go b/internal/ui/model/sidebar.go new file mode 100644 index 0000000000000000000000000000000000000000..e0f3210dbeff7efbcbe82b2a1a478713d1fe8402 --- /dev/null +++ b/internal/ui/model/sidebar.go @@ -0,0 +1,81 @@ +package model + +import ( + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/logo" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/version" + "github.com/charmbracelet/lipgloss/v2" +) + +// SidebarModel is the model for the sidebar UI component. +type SidebarModel struct { + com *common.Common + + // width of the sidebar. + width int + + // Cached rendered logo string. + logo string + // Cached cwd string. + cwd string + + // TODO: lsp, files, session + + // Whether to render the sidebar in compact mode. + compact bool +} + +// NewSidebarModel creates a new SidebarModel instance. +func NewSidebarModel(com *common.Common) *SidebarModel { + return &SidebarModel{ + com: com, + compact: true, + cwd: com.Config.WorkingDir(), + } +} + +// Init initializes the sidebar model. +func (m *SidebarModel) Init() tea.Cmd { + return nil +} + +// Update updates the sidebar model based on incoming messages. +func (m *SidebarModel) Update(msg tea.Msg) (*SidebarModel, tea.Cmd) { + return m, nil +} + +// View renders the sidebar model as a string. +func (m *SidebarModel) View() string { + s := m.com.Styles.SidebarFull + if m.compact { + s = m.com.Styles.SidebarCompact + } + + blocks := []string{ + m.logo, + } + + return s.Render(lipgloss.JoinVertical( + lipgloss.Top, + blocks..., + )) +} + +// SetWidth sets the width of the sidebar and updates the logo accordingly. +func (m *SidebarModel) SetWidth(width int) { + m.logo = logoBlock(m.com.Styles, width) + m.width = width +} + +func logoBlock(t *styles.Styles, width int) string { + return logo.Render(version.Version, true, logo.Opts{ + FieldColor: t.LogoFieldColor, + TitleColorA: t.LogoTitleColorA, + TitleColorB: t.LogoTitleColorB, + CharmColor: t.LogoCharmColor, + VersionColor: t.LogoVersionColor, + Width: max(0, width-2), + }) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index cb1a96b5557e59de8b6099e803ba1f806550b769..addcd68f67f6451eb4f2a59ae30d164ade8613e5 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -36,6 +36,7 @@ type UI struct { chat *ChatModel editor *EditorModel + side *SidebarModel dialog *dialog.Overlay help help.Model @@ -58,6 +59,7 @@ func New(com *common.Common, app *app.App) *UI { dialog: dialog.NewOverlay(), keyMap: DefaultKeyMap(), editor: NewEditorModel(com, app), + side: NewSidebarModel(com), help: help.New(), } } @@ -88,9 +90,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case tea.WindowSizeMsg: - m.updateLayout(msg.Width, msg.Height) - m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy()) - m.help.Width = m.layout.help.Dx() + m.updateLayoutAndSize(msg.Width, msg.Height) case tea.KeyPressMsg: if m.dialog.HasDialogs() { m.updateDialogs(msg, &cmds) @@ -106,7 +106,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.keyMap.Help): m.help.ShowAll = !m.help.ShowAll - m.updateLayout(m.layout.area.Dx(), m.layout.area.Dy()) + m.updateLayoutAndSize(m.layout.area.Dx(), m.layout.area.Dy()) case key.Matches(msg, m.keyMap.Quit): if !m.dialog.ContainsDialog(dialog.QuitDialogID) { m.dialog.AddDialog(dialog.NewQuit(m.com)) @@ -172,12 +172,8 @@ func (m *UI) View() tea.View { Background(lipgloss.ANSIColor(rand.Intn(256))). Render(" Main View "), ).X(chatRect.Min.X).Y(chatRect.Min.Y), - lipgloss.NewLayer( - lipgloss.NewStyle().Width(sideRect.Dx()). - Height(sideRect.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Side View "), - ).X(sideRect.Min.X).Y(sideRect.Min.Y), + lipgloss.NewLayer(m.side.View()). + X(sideRect.Min.X).Y(sideRect.Min.Y), lipgloss.NewLayer(m.editor.View()). X(editRect.Min.X).Y(editRect.Min.Y), lipgloss.NewLayer(m.help.View(helpKeyMap)). @@ -244,9 +240,9 @@ func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { } } -// updateLayout updates the layout based on the given terminal width and -// height given in cells. -func (m *UI) updateLayout(w, h int) { +// updateLayoutAndSize updates the layout and sub-models sizes based on the +// given terminal width and height given in cells. +func (m *UI) updateLayoutAndSize(w, h int) { // The screen area we're working with area := image.Rect(0, 0, w, h) helpKeyMap := m.focusedKeyMap() @@ -284,6 +280,11 @@ func (m *UI) updateLayout(w, h int) { sidebar: sideRect, help: helpRect, } + + // Update sub-model sizes + m.side.SetWidth(m.layout.sidebar.Dx()) + m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy()) + m.help.Width = m.layout.help.Dx() } // layout defines the positioning of UI elements. diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 297cadfe721432a40055b31c2817d7ecbe3237b8..37ae34916bdcf14717a9a4d1395dfa5e754649b9 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -1,6 +1,8 @@ package styles import ( + "image/color" + "github.com/charmbracelet/bubbles/v2/filepicker" "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/textarea" @@ -110,6 +112,17 @@ type Styles struct { EditorPromptYoloIconBlurred lipgloss.Style EditorPromptYoloDotsFocused lipgloss.Style EditorPromptYoloDotsBlurred lipgloss.Style + + // Logo + LogoFieldColor color.Color + LogoTitleColorA color.Color + LogoTitleColorB color.Color + LogoCharmColor color.Color + LogoVersionColor color.Color + + // Sidebar + SidebarFull lipgloss.Style + SidebarCompact lipgloss.Style } func DefaultStyles() Styles { @@ -538,6 +551,17 @@ func DefaultStyles() Styles { s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::") s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid) + // Logo colors + s.LogoFieldColor = primary + s.LogoTitleColorA = secondary + s.LogoTitleColorB = primary + s.LogoCharmColor = secondary + s.LogoVersionColor = primary + + // Sidebar + s.SidebarFull = lipgloss.NewStyle().Padding(1, 1) + s.SidebarCompact = s.SidebarFull.PaddingTop(0) + return s } From 7bc57e7e1689bf2a284aa3120c7e4e92868a4d09 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 17 Nov 2025 16:46:01 -0500 Subject: [PATCH 010/335] fix(ui): use Content instead of Layer for main view --- internal/ui/model/ui.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index dadb30b245c7a67f3afb5c3e785719a22472c6dc..74f7a4d9f2def5594a9d0cfbe047b41a23538763 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -182,7 +182,7 @@ func (m *UI) View() tea.View { layers = append(layers, mainLayer) - v.Layer = lipgloss.NewCanvas(layers...) + v.Content = lipgloss.NewCanvas(layers...) if m.sendProgressBar && m.app != nil && m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() { // HACK: use a random percentage to prevent ghostty from hiding it // after a timeout. From ba9e4528b292d4ad01e90fda3726829adfd7e14b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 17 Nov 2025 16:56:16 -0500 Subject: [PATCH 011/335] refactor(ui): pass app.App to common.Common and access config via common --- internal/cmd/root.go | 4 ++-- internal/ui/common/common.go | 12 +++++++++--- internal/ui/model/editor.go | 11 ++++------- internal/ui/model/sidebar.go | 2 +- internal/ui/model/ui.go | 9 +++------ 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 606cbe351318898708c79e95bf67d58b640c4ab4..9684eb17ff5710ea90543a66451f2ba307af7802 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -87,8 +87,8 @@ crush -y // Set up the TUI. var env uv.Environ = os.Environ() - com := common.DefaultCommon(app.Config()) - ui := ui.New(com, app) + com := common.DefaultCommon(app) + ui := ui.New(com) ui.QueryVersion = shouldQueryTerminalVersion(env) program := tea.NewProgram( ui, diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 3583160faa40a2a2f507f4d56376fc619cb45d09..68d01c77901a98263defa4d28fe4f80af4ac3cfc 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -3,6 +3,7 @@ package common import ( "image" + "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/styles" uv "github.com/charmbracelet/ultraviolet" @@ -10,15 +11,20 @@ import ( // Common defines common UI options and configurations. type Common struct { - Config *config.Config + App *app.App Styles *styles.Styles } +// Config returns the configuration associated with this [Common] instance. +func (c *Common) Config() *config.Config { + return c.App.Config() +} + // DefaultCommon returns the default common UI configurations. -func DefaultCommon(cfg *config.Config) *Common { +func DefaultCommon(app *app.App) *Common { s := styles.DefaultStyles() return &Common{ - Config: cfg, + App: app, Styles: &s, } } diff --git a/internal/ui/model/editor.go b/internal/ui/model/editor.go index 0e360821165f111e68d15dd7b1d84310e6c846d0..d3c5afd703904473854617ddeda5e394952f7613 100644 --- a/internal/ui/model/editor.go +++ b/internal/ui/model/editor.go @@ -6,7 +6,6 @@ import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/ui/common" ) @@ -61,7 +60,6 @@ func DefaultEditorKeyMap() EditorKeyMap { // EditorModel represents the editor UI model. type EditorModel struct { com *common.Common - app *app.App keyMap EditorKeyMap textarea *textarea.Model @@ -73,7 +71,7 @@ type EditorModel struct { } // NewEditorModel creates a new instance of EditorModel. -func NewEditorModel(com *common.Common, app *app.App) *EditorModel { +func NewEditorModel(com *common.Common) *EditorModel { ta := textarea.New() ta.SetStyles(com.Styles.TextArea) ta.ShowLineNumbers = false @@ -82,7 +80,6 @@ func NewEditorModel(com *common.Common, app *app.App) *EditorModel { ta.Focus() e := &EditorModel{ com: com, - app: app, keyMap: DefaultEditorKeyMap(), textarea: ta, } @@ -108,12 +105,12 @@ func (m *EditorModel) Update(msg tea.Msg) (*EditorModel, tea.Cmd) { cmds = append(cmds, cmd) // Textarea placeholder logic - if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() { + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { m.textarea.Placeholder = m.workingPlaceholder } else { m.textarea.Placeholder = m.readyPlaceholder } - if m.app.Permissions.SkipRequests() { + if m.com.App.Permissions.SkipRequests() { m.textarea.Placeholder = "Yolo mode!" } @@ -183,7 +180,7 @@ func (m *EditorModel) SetSize(width, height int) { } func (m *EditorModel) setEditorPrompt() { - if m.app.Permissions.SkipRequests() { + if m.com.App.Permissions.SkipRequests() { m.textarea.SetPromptFunc(4, m.yoloPromptFunc) return } diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 1f0116286878cd4487dde60f772cd07a31385205..e6038792da1aa2f3b2f11c05ea09d802994401b3 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -32,7 +32,7 @@ func NewSidebarModel(com *common.Common) *SidebarModel { return &SidebarModel{ com: com, compact: true, - cwd: com.Config.WorkingDir(), + cwd: com.Config().WorkingDir(), } } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 74f7a4d9f2def5594a9d0cfbe047b41a23538763..b313deb815439dafc225d574ae3a3b2084675ddd 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -10,7 +10,6 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" uv "github.com/charmbracelet/ultraviolet" @@ -27,7 +26,6 @@ const ( // UI represents the main user interface model. type UI struct { - app *app.App com *common.Common state uiState @@ -52,13 +50,12 @@ type UI struct { } // New creates a new instance of the [UI] model. -func New(com *common.Common, app *app.App) *UI { +func New(com *common.Common) *UI { return &UI{ - app: app, com: com, dialog: dialog.NewOverlay(), keyMap: DefaultKeyMap(), - editor: NewEditorModel(com, app), + editor: NewEditorModel(com), side: NewSidebarModel(com), help: help.New(), } @@ -183,7 +180,7 @@ func (m *UI) View() tea.View { layers = append(layers, mainLayer) v.Content = lipgloss.NewCanvas(layers...) - if m.sendProgressBar && m.app != nil && m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() { + if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { // HACK: use a random percentage to prevent ghostty from hiding it // after a timeout. v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100)) From d9e67367226436d629078fe392e38bc2d61aefeb Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 17 Nov 2025 17:59:06 -0500 Subject: [PATCH 012/335] feat(ui): simplify editor and embed into main UI model --- internal/ui/model/editor.go | 235 ------------------------------- internal/ui/model/keys.go | 64 ++++++++- internal/ui/model/ui.go | 267 ++++++++++++++++++++++++++++++------ 3 files changed, 285 insertions(+), 281 deletions(-) delete mode 100644 internal/ui/model/editor.go diff --git a/internal/ui/model/editor.go b/internal/ui/model/editor.go deleted file mode 100644 index d3c5afd703904473854617ddeda5e394952f7613..0000000000000000000000000000000000000000 --- a/internal/ui/model/editor.go +++ /dev/null @@ -1,235 +0,0 @@ -package model - -import ( - "math/rand" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textarea" - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/ui/common" -) - -type EditorKeyMap struct { - AddFile key.Binding - SendMessage key.Binding - OpenEditor key.Binding - Newline key.Binding - - // Attachments key maps - AttachmentDeleteMode key.Binding - Escape key.Binding - DeleteAllAttachments 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"), - ), - 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"), - ), - } -} - -// EditorModel represents the editor UI model. -type EditorModel struct { - com *common.Common - - keyMap EditorKeyMap - textarea *textarea.Model - - attachments []any // TODO: Implement attachments - - readyPlaceholder string - workingPlaceholder string -} - -// NewEditorModel creates a new instance of EditorModel. -func NewEditorModel(com *common.Common) *EditorModel { - ta := textarea.New() - ta.SetStyles(com.Styles.TextArea) - ta.ShowLineNumbers = false - ta.CharLimit = -1 - ta.SetVirtualCursor(false) - ta.Focus() - e := &EditorModel{ - com: com, - keyMap: DefaultEditorKeyMap(), - textarea: ta, - } - - e.setEditorPrompt() - e.randomizePlaceholders() - e.textarea.Placeholder = e.readyPlaceholder - - return e -} - -// Init initializes the editor model. -func (m *EditorModel) Init() tea.Cmd { - return nil -} - -// Update handles updates to the editor model. -func (m *EditorModel) Update(msg tea.Msg) (*EditorModel, tea.Cmd) { - var cmds []tea.Cmd - var cmd tea.Cmd - - m.textarea, cmd = m.textarea.Update(msg) - cmds = append(cmds, cmd) - - // Textarea placeholder logic - if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { - m.textarea.Placeholder = m.workingPlaceholder - } else { - m.textarea.Placeholder = m.readyPlaceholder - } - if m.com.App.Permissions.SkipRequests() { - m.textarea.Placeholder = "Yolo mode!" - } - - // TODO: Add attachments - - return m, tea.Batch(cmds...) -} - -// View renders the editor model. -func (m *EditorModel) View() string { - return m.textarea.View() -} - -// ShortHelp returns the short help view for the editor model. -func (m *EditorModel) ShortHelp() []key.Binding { - k := m.keyMap - binds := []key.Binding{ - k.AddFile, - k.SendMessage, - k.OpenEditor, - k.Newline, - } - - if len(m.attachments) > 0 { - binds = append(binds, - k.AttachmentDeleteMode, - k.DeleteAllAttachments, - k.Escape, - ) - } - - return binds -} - -// FullHelp returns the full help view for the editor model. -func (m *EditorModel) FullHelp() [][]key.Binding { - return [][]key.Binding{ - m.ShortHelp(), - } -} - -// Cursor returns the relative cursor position of the editor. -func (m *EditorModel) Cursor() *tea.Cursor { - return m.textarea.Cursor() -} - -// Blur implements Container. -func (m *EditorModel) Blur() tea.Cmd { - m.textarea.Blur() - return nil -} - -// Focus implements Container. -func (m *EditorModel) Focus() tea.Cmd { - return m.textarea.Focus() -} - -// Focused returns whether the editor is focused. -func (m *EditorModel) Focused() bool { - return m.textarea.Focused() -} - -// SetSize sets the size of the editor. -func (m *EditorModel) SetSize(width, height int) { - m.textarea.SetWidth(width) - m.textarea.SetHeight(height) -} - -func (m *EditorModel) setEditorPrompt() { - if m.com.App.Permissions.SkipRequests() { - m.textarea.SetPromptFunc(4, m.yoloPromptFunc) - return - } - m.textarea.SetPromptFunc(4, m.normalPromptFunc) -} - -func (m *EditorModel) normalPromptFunc(info textarea.PromptInfo) string { - t := m.com.Styles - if info.LineNumber == 0 { - return " > " - } - if info.Focused { - return t.EditorPromptNormalFocused.Render() - } - return t.EditorPromptNormalBlurred.Render() -} - -func (m *EditorModel) yoloPromptFunc(info textarea.PromptInfo) string { - t := m.com.Styles - if info.LineNumber == 0 { - if info.Focused { - return t.EditorPromptYoloIconFocused.Render() - } else { - return t.EditorPromptYoloIconBlurred.Render() - } - } - if info.Focused { - return t.EditorPromptYoloDotsFocused.Render() - } - return t.EditorPromptYoloDotsBlurred.Render() -} - -var readyPlaceholders = [...]string{ - "Ready!", - "Ready...", - "Ready?", - "Ready for instructions", -} - -var workingPlaceholders = [...]string{ - "Working!", - "Working...", - "Brrrrr...", - "Prrrrrrrr...", - "Processing...", - "Thinking...", -} - -func (m *EditorModel) randomizePlaceholders() { - m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] - m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] -} diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index d6b51c374b55dffd55be76405724b40d36a39c05..ff7a9344b54182cafc9cbaf979cc3c0112107743 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -3,16 +3,32 @@ package model import "charm.land/bubbles/v2/key" type KeyMap struct { + Editor struct { + AddFile key.Binding + SendMessage key.Binding + OpenEditor key.Binding + Newline key.Binding + AddImage key.Binding + MentionFile key.Binding + + // Attachments key maps + AttachmentDeleteMode key.Binding + Escape key.Binding + DeleteAllAttachments key.Binding + } + + // Global key maps Quit key.Binding Help key.Binding Commands key.Binding + Models key.Binding Suspend key.Binding Sessions key.Binding Tab key.Binding } func DefaultKeyMap() KeyMap { - return KeyMap{ + km := KeyMap{ Quit: key.NewBinding( key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit"), @@ -25,6 +41,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "commands"), ), + Models: key.NewBinding( + key.WithKeys("ctrl+m", "ctrl+l"), + key.WithHelp("ctrl+l", "models"), + ), Suspend: key.NewBinding( key.WithKeys("ctrl+z"), key.WithHelp("ctrl+z", "suspend"), @@ -38,4 +58,46 @@ func DefaultKeyMap() KeyMap { key.WithHelp("tab", "change focus"), ), } + + km.Editor.AddFile = key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "add file"), + ) + km.Editor.SendMessage = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "send"), + ) + km.Editor.OpenEditor = key.NewBinding( + key.WithKeys("ctrl+o"), + key.WithHelp("ctrl+o", "open editor"), + ) + km.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 tex + // to reflect that. + key.WithHelp("ctrl+j", "newline"), + ) + km.Editor.AddImage = key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "add image"), + ) + km.Editor.MentionFile = key.NewBinding( + key.WithKeys("@"), + key.WithHelp("@", "mention file"), + ) + km.Editor.AttachmentDeleteMode = key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), + ) + km.Editor.Escape = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "cancel delete mode"), + ) + km.Editor.DeleteAllAttachments = key.NewBinding( + key.WithKeys("r"), + key.WithHelp("ctrl+r+r", "delete all attachments"), + ) + + return km } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index b313deb815439dafc225d574ae3a3b2084675ddd..359bfb60b07817ded09347524698d33eab452994 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -8,8 +8,10 @@ import ( "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" uv "github.com/charmbracelet/ultraviolet" @@ -20,20 +22,21 @@ type uiState uint8 // Possible uiState values. const ( - uiChat uiState = iota - uiEdit + uiEdit uiState = iota + uiChat ) // UI represents the main user interface model. type UI struct { - com *common.Common + com *common.Common + sess *session.Session state uiState keyMap KeyMap + keyenh tea.KeyboardEnhancementsMsg chat *ChatModel - editor *EditorModel side *SidebarModel dialog *dialog.Overlay help help.Model @@ -47,18 +50,40 @@ type UI struct { // QueryVersion instructs the TUI to query for the terminal version when it // starts. QueryVersion bool + + // Editor components + textarea textarea.Model + + attachments []any // TODO: Implement attachments + + readyPlaceholder string + workingPlaceholder string } // New creates a new instance of the [UI] model. func New(com *common.Common) *UI { - return &UI{ - com: com, - dialog: dialog.NewOverlay(), - keyMap: DefaultKeyMap(), - editor: NewEditorModel(com), - side: NewSidebarModel(com), - help: help.New(), + // Editor components + ta := textarea.New() + ta.SetStyles(com.Styles.TextArea) + ta.ShowLineNumbers = false + ta.CharLimit = -1 + ta.SetVirtualCursor(false) + ta.Focus() + + ui := &UI{ + com: com, + dialog: dialog.NewOverlay(), + keyMap: DefaultKeyMap(), + side: NewSidebarModel(com), + help: help.New(), + textarea: ta, } + + ui.setEditorPrompt() + ui.randomizePlaceholders() + ui.textarea.Placeholder = ui.readyPlaceholder + + return ui } // Init initializes the UI model. @@ -73,6 +98,7 @@ func (m *UI) Init() tea.Cmd { // Update handles updates to the UI model. func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd + hasDialogs := m.dialog.HasDialogs() switch msg := msg.(type) { case tea.EnvMsg: // Is this Windows Terminal? @@ -88,18 +114,30 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.WindowSizeMsg: m.updateLayoutAndSize(msg.Width, msg.Height) + case tea.KeyboardEnhancementsMsg: + m.keyenh = msg + if msg.SupportsKeyDisambiguation() { + m.keyMap.Models.SetHelp("ctrl+m", "models") + m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline") + } case tea.KeyPressMsg: - if m.dialog.HasDialogs() { + if hasDialogs { m.updateDialogs(msg, &cmds) - } else { + } + } + + if !hasDialogs { + // This branch only handles UI elements when there's no dialog shown. + switch msg := msg.(type) { + case tea.KeyPressMsg: switch { case key.Matches(msg, m.keyMap.Tab): if m.state == uiChat { m.state = uiEdit - cmds = append(cmds, m.editor.Focus()) + cmds = append(cmds, m.textarea.Focus()) } else { m.state = uiChat - cmds = append(cmds, m.editor.Blur()) + m.textarea.Blur() } case key.Matches(msg, m.keyMap.Help): m.help.ShowAll = !m.help.ShowAll @@ -109,10 +147,31 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.dialog.AddDialog(dialog.NewQuit(m.com)) return m, nil } + case key.Matches(msg, m.keyMap.Commands): + // TODO: Implement me + case key.Matches(msg, m.keyMap.Models): + // TODO: Implement me + case key.Matches(msg, m.keyMap.Sessions): + // TODO: Implement me default: m.updateFocused(msg, &cmds) } } + + // This logic gets triggered on any message type, but should it? + switch m.state { + case uiChat: + case uiEdit: + // Textarea placeholder logic + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + m.textarea.Placeholder = m.workingPlaceholder + } else { + m.textarea.Placeholder = m.readyPlaceholder + } + if m.com.App.Permissions.SkipRequests() { + m.textarea.Placeholder = "Yolo mode!" + } + } } return m, tea.Batch(cmds...) @@ -126,7 +185,7 @@ func (m *UI) View() tea.View { layers := []*lipgloss.Layer{} // Determine the help key map based on focus - helpKeyMap := m.focusedKeyMap() + var helpKeyMap help.KeyMap = m // The screen areas we're working with area := m.layout.area @@ -137,11 +196,6 @@ func (m *UI) View() tea.View { if m.dialog.HasDialogs() { if dialogView := m.dialog.View(); dialogView != "" { - // If the dialog has its own help, use that instead - if len(m.dialog.FullHelp()) > 0 || len(m.dialog.ShortHelp()) > 0 { - helpKeyMap = m.dialog - } - dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView) dialogArea := common.CenterRect(area, dialogWidth, dialogHeight) layers = append(layers, @@ -153,8 +207,8 @@ func (m *UI) View() tea.View { } } - if m.state == uiEdit && m.editor.Focused() { - cur := m.editor.Cursor() + if m.state == uiEdit && m.textarea.Focused() { + cur := m.textarea.Cursor() cur.X++ // Adjust for app margins cur.Y += editRect.Min.Y v.Cursor = cur @@ -171,7 +225,7 @@ func (m *UI) View() tea.View { ).X(chatRect.Min.X).Y(chatRect.Min.Y), lipgloss.NewLayer(m.side.View()). X(sideRect.Min.X).Y(sideRect.Min.Y), - lipgloss.NewLayer(m.editor.View()). + lipgloss.NewLayer(m.textarea.View()). X(editRect.Min.X).Y(editRect.Min.Y), lipgloss.NewLayer(m.help.View(helpKeyMap)). X(helpRect.Min.X).Y(helpRect.Min.Y), @@ -189,11 +243,82 @@ func (m *UI) View() tea.View { return v } -func (m *UI) focusedKeyMap() help.KeyMap { - if m.state == uiChat { - return m.chat +// ShortHelp implements [help.KeyMap]. +func (m *UI) ShortHelp() []key.Binding { + var binds []key.Binding + k := &m.keyMap + + if m.sess == nil { + // no session selected + binds = append(binds, + k.Commands, + k.Models, + k.Editor.Newline, + k.Quit, + k.Help, + ) + } else { + // we have a session } - return m.editor + + // switch m.state { + // case uiChat: + // case uiEdit: + // binds = append(binds, + // k.Editor.AddFile, + // k.Editor.SendMessage, + // k.Editor.OpenEditor, + // k.Editor.Newline, + // ) + // + // if len(m.attachments) > 0 { + // binds = append(binds, + // k.Editor.AttachmentDeleteMode, + // k.Editor.DeleteAllAttachments, + // k.Editor.Escape, + // ) + // } + // } + + return binds +} + +// FullHelp implements [help.KeyMap]. +func (m *UI) FullHelp() [][]key.Binding { + var binds [][]key.Binding + k := &m.keyMap + help := k.Help + help.SetHelp("ctrl+g", "less") + + if m.sess == nil { + // no session selected + binds = append(binds, + []key.Binding{ + k.Commands, + k.Models, + k.Sessions, + }, + []key.Binding{ + k.Editor.Newline, + k.Editor.AddImage, + k.Editor.MentionFile, + k.Editor.OpenEditor, + }, + []key.Binding{ + help, + }, + ) + } else { + // we have a session + } + + // switch m.state { + // case uiChat: + // case uiEdit: + // binds = append(binds, m.ShortHelp()) + // } + + return binds } // updateDialogs updates the dialog overlay with the given message and appends @@ -213,7 +338,16 @@ func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { case uiChat: m.updateChat(msg, cmds) case uiEdit: - m.updateEditor(msg, cmds) + switch { + case key.Matches(msg, m.keyMap.Editor.Newline): + m.textarea.InsertRune('\n') + } + + ta, cmd := m.textarea.Update(msg) + m.textarea = ta + if cmd != nil { + *cmds = append(*cmds, cmd) + } } } @@ -227,26 +361,13 @@ func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { } } -// updateEditor updates the editor model with the given message and appends any -// resulting commands to the cmds slice. -func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { - updatedEditor, cmd := m.editor.Update(msg) - m.editor = updatedEditor - if cmd != nil { - *cmds = append(*cmds, cmd) - } -} - // updateLayoutAndSize updates the layout and sub-models sizes based on the // given terminal width and height given in cells. func (m *UI) updateLayoutAndSize(w, h int) { // The screen area we're working with area := image.Rect(0, 0, w, h) - helpKeyMap := m.focusedKeyMap() + var helpKeyMap help.KeyMap = m helpHeight := 1 - if m.dialog.HasDialogs() && len(m.dialog.FullHelp()) > 0 && len(m.dialog.ShortHelp()) > 0 { - helpKeyMap = m.dialog - } if m.help.ShowAll { for _, row := range helpKeyMap.FullHelp() { helpHeight = max(helpHeight, len(row)) @@ -280,8 +401,9 @@ func (m *UI) updateLayoutAndSize(w, h int) { // Update sub-model sizes m.side.SetWidth(m.layout.sidebar.Dx()) - m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy()) - m.help.Width = m.layout.help.Dx() + m.textarea.SetWidth(m.layout.editor.Dx()) + m.textarea.SetHeight(m.layout.editor.Dy()) + m.help.SetWidth(m.layout.help.Dx()) } // layout defines the positioning of UI elements. @@ -304,3 +426,58 @@ type layout struct { // help is the area for the help view. help uv.Rectangle } + +func (m *UI) setEditorPrompt() { + if m.com.App.Permissions.SkipRequests() { + m.textarea.SetPromptFunc(4, m.yoloPromptFunc) + return + } + m.textarea.SetPromptFunc(4, m.normalPromptFunc) +} + +func (m *UI) normalPromptFunc(info textarea.PromptInfo) string { + t := m.com.Styles + if info.LineNumber == 0 { + return " > " + } + if info.Focused { + return t.EditorPromptNormalFocused.Render() + } + return t.EditorPromptNormalBlurred.Render() +} + +func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string { + t := m.com.Styles + if info.LineNumber == 0 { + if info.Focused { + return t.EditorPromptYoloIconFocused.Render() + } else { + return t.EditorPromptYoloIconBlurred.Render() + } + } + if info.Focused { + return t.EditorPromptYoloDotsFocused.Render() + } + return t.EditorPromptYoloDotsBlurred.Render() +} + +var readyPlaceholders = [...]string{ + "Ready!", + "Ready...", + "Ready?", + "Ready for instructions", +} + +var workingPlaceholders = [...]string{ + "Working!", + "Working...", + "Brrrrr...", + "Prrrrrrrr...", + "Processing...", + "Thinking...", +} + +func (m *UI) randomizePlaceholders() { + m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] + m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] +} From efaa956d77b90321e9979b78405f28b1b2d358dd Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 19 Nov 2025 17:22:36 +0100 Subject: [PATCH 013/335] refactor(ui): rework init and support different layouts (#1463) --- internal/ui/common/button.go | 69 ++++++ internal/ui/dialog/quit.go | 26 +- internal/ui/model/keys.go | 19 ++ internal/ui/model/sidebar.go | 16 +- internal/ui/model/ui.go | 449 +++++++++++++++++++++++++++-------- internal/ui/styles/styles.go | 23 +- 6 files changed, 459 insertions(+), 143 deletions(-) create mode 100644 internal/ui/common/button.go diff --git a/internal/ui/common/button.go b/internal/ui/common/button.go new file mode 100644 index 0000000000000000000000000000000000000000..90a2dc929a004e734a18e69b874b36cbd0f4f667 --- /dev/null +++ b/internal/ui/common/button.go @@ -0,0 +1,69 @@ +package common + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ButtonOpts defines the configuration for a single button +type ButtonOpts struct { + // Text is the button label + Text string + // UnderlineIndex is the 0-based index of the character to underline (-1 for none) + UnderlineIndex int + // Selected indicates whether this button is currently selected + Selected bool + // Padding inner horizontal padding defaults to 2 if this is 0 + Padding int +} + +// Button creates a button with an underlined character and selection state +func Button(t *styles.Styles, opts ButtonOpts) string { + // Select style based on selection state + style := t.ButtonBlur + if opts.Selected { + style = t.ButtonFocus + } + + text := opts.Text + if opts.Padding == 0 { + opts.Padding = 2 + } + + // the index is out of bound + if opts.UnderlineIndex > -1 && opts.UnderlineIndex > len(text)-1 { + opts.UnderlineIndex = -1 + } + + text = style.Padding(0, opts.Padding).Render(text) + + if opts.UnderlineIndex != -1 { + text = lipgloss.StyleRanges(text, lipgloss.NewRange(opts.Padding+opts.UnderlineIndex, opts.Padding+opts.UnderlineIndex+1, style.Underline(true))) + } + + return text +} + +// ButtonGroup creates a row of selectable buttons +// Spacing is the separator between buttons +// Use " " or similar for horizontal layout +// Use "\n" for vertical layout +// Defaults to " " (horizontal) +func ButtonGroup(t *styles.Styles, buttons []ButtonOpts, spacing string) string { + if len(buttons) == 0 { + return "" + } + + if spacing == "" { + spacing = " " + } + + parts := make([]string, len(buttons)) + for i, button := range buttons { + parts[i] = Button(t, button) + } + + return strings.Join(parts, spacing) +} diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go index 7456c094245b9ba5db44098a58468c06c43f466b..1ec187d36654420a61e367bc51829a44d4c3a14d 100644 --- a/internal/ui/dialog/quit.go +++ b/internal/ui/dialog/quit.go @@ -60,8 +60,9 @@ type Quit struct { // NewQuit creates a new quit confirmation dialog. func NewQuit(com *common.Common) *Quit { q := &Quit{ - com: com, - keyMap: DefaultQuitKeyMap(), + com: com, + keyMap: DefaultQuitKeyMap(), + selectedNo: true, } return q } @@ -98,24 +99,11 @@ func (q *Quit) Update(msg tea.Msg) (Dialog, tea.Cmd) { func (q *Quit) View() string { const question = "Are you sure you want to quit?" baseStyle := q.com.Styles.Base - yesStyle := q.com.Styles.ButtonSelected - noStyle := q.com.Styles.ButtonUnselected - - if q.selectedNo { - noStyle = q.com.Styles.ButtonSelected - yesStyle = q.com.Styles.ButtonUnselected + buttonOpts := []common.ButtonOpts{ + {Text: "Yep!", Selected: !q.selectedNo, Padding: 3}, + {Text: "Nope", Selected: q.selectedNo, Padding: 3}, } - - 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), - ) - + buttons := common.ButtonGroup(q.com.Styles, buttonOpts, " ") content := baseStyle.Render( lipgloss.JoinVertical( lipgloss.Center, diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index ff7a9344b54182cafc9cbaf979cc3c0112107743..cf5d2721372f5a7d49de85d6b2155d8dfb24e4af 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -17,6 +17,12 @@ type KeyMap struct { DeleteAllAttachments key.Binding } + Initialize struct { + Yes, + No, + Switch key.Binding + } + // Global key maps Quit key.Binding Help key.Binding @@ -99,5 +105,18 @@ func DefaultKeyMap() KeyMap { key.WithHelp("ctrl+r+r", "delete all attachments"), ) + km.Initialize.Yes = key.NewBinding( + key.WithKeys("y", "Y"), + key.WithHelp("y", "yes"), + ) + km.Initialize.No = key.NewBinding( + key.WithKeys("n", "N", "esc", "alt+esc"), + key.WithHelp("n", "no"), + ) + km.Initialize.Switch = key.NewBinding( + key.WithKeys("left", "right", "tab"), + key.WithHelp("tab", "switch"), + ) + return km } diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index e6038792da1aa2f3b2f11c05ea09d802994401b3..2f84565c98428126df612020ac0af35d88f46c78 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -4,9 +4,6 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/ui/common" - "github.com/charmbracelet/crush/internal/ui/logo" - "github.com/charmbracelet/crush/internal/ui/styles" - "github.com/charmbracelet/crush/internal/version" ) // SidebarModel is the model for the sidebar UI component. @@ -65,17 +62,6 @@ func (m *SidebarModel) View() string { // SetWidth sets the width of the sidebar and updates the logo accordingly. func (m *SidebarModel) SetWidth(width int) { - m.logo = logoBlock(m.com.Styles, width) + m.logo = renderLogo(m.com.Styles, true, width) m.width = width } - -func logoBlock(t *styles.Styles, width int) string { - return logo.Render(version.Version, true, logo.Opts{ - FieldColor: t.LogoFieldColor, - TitleColorA: t.LogoTitleColorA, - TitleColorB: t.LogoTitleColorB, - CharmColor: t.LogoCharmColor, - VersionColor: t.LogoVersionColor, - Width: max(0, width-2), - }) -} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 359bfb60b07817ded09347524698d33eab452994..fb443e9fc35a4ebb583e95ab8be07be19deab37a 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1,8 +1,10 @@ package model import ( + "fmt" "image" "math/rand" + "os" "slices" "strings" @@ -11,19 +13,36 @@ import ( "charm.land/bubbles/v2/textarea" 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/session" "github.com/charmbracelet/crush/internal/ui/common" "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/version" uv "github.com/charmbracelet/ultraviolet" ) -// uiState represents the current focus state of the UI. +// uiFocusState represents the current focus state of the UI. +type uiFocusState uint8 + +// Possible uiFocusState values. +const ( + uiFocusNone uiFocusState = iota + uiFocusEditor + uiFocusMain +) + type uiState uint8 // Possible uiState values. const ( - uiEdit uiState = iota + uiConfigure uiState = iota + uiInitialize + uiLanding uiChat + uiChatCompact ) // UI represents the main user interface model. @@ -31,6 +50,7 @@ type UI struct { com *common.Common sess *session.Session + focus uiFocusState state uiState keyMap KeyMap @@ -41,6 +61,9 @@ type UI struct { dialog *dialog.Overlay help help.Model + // header is the last cached header logo + header string + layout layout // sendProgressBar instructs the TUI to send progress bar updates to the @@ -58,6 +81,9 @@ type UI struct { readyPlaceholder string workingPlaceholder string + + // Initialize state + yesInitializeSelected bool } // New creates a new instance of the [UI] model. @@ -76,7 +102,23 @@ func New(com *common.Common) *UI { keyMap: DefaultKeyMap(), side: NewSidebarModel(com), help: help.New(), + focus: uiFocusNone, + state: uiConfigure, textarea: ta, + + // initialize + yesInitializeSelected: true, + } + // If no provider is configured show the user the provider list + if !com.Config().IsConfigured() { + ui.state = uiConfigure + // if the project needs initialization show the user the question + } else if n, _ := config.ProjectNeedsInitialization(); n { + ui.state = uiInitialize + // otherwise go to the landing UI + } else { + ui.state = uiLanding + ui.focus = uiFocusEditor } ui.setEditorPrompt() @@ -91,7 +133,6 @@ func (m *UI) Init() tea.Cmd { if m.QueryVersion { return tea.RequestTerminalVersion } - return nil } @@ -132,11 +173,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: switch { case key.Matches(msg, m.keyMap.Tab): - if m.state == uiChat { - m.state = uiEdit + if m.focus == uiFocusMain { + m.focus = uiFocusEditor cmds = append(cmds, m.textarea.Focus()) } else { - m.state = uiChat + m.focus = uiFocusMain m.textarea.Blur() } case key.Matches(msg, m.keyMap.Help): @@ -159,9 +200,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // This logic gets triggered on any message type, but should it? - switch m.state { - case uiChat: - case uiEdit: + switch m.focus { + case uiFocusMain: + case uiFocusEditor: // Textarea placeholder logic if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { m.textarea.Placeholder = m.workingPlaceholder @@ -181,6 +222,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *UI) View() tea.View { var v tea.View v.AltScreen = true + v.BackgroundColor = m.com.Styles.Background layers := []*lipgloss.Layer{} @@ -189,7 +231,8 @@ func (m *UI) View() tea.View { // The screen areas we're working with area := m.layout.area - chatRect := m.layout.chat + headerRect := m.layout.header + mainRect := m.layout.main sideRect := m.layout.sidebar editRect := m.layout.editor helpRect := m.layout.help @@ -207,7 +250,7 @@ func (m *UI) View() tea.View { } } - if m.state == uiEdit && m.textarea.Focused() { + if m.focus == uiFocusEditor && m.textarea.Focused() { cur := m.textarea.Cursor() cur.X++ // Adjust for app margins cur.Y += editRect.Min.Y @@ -215,24 +258,70 @@ func (m *UI) View() tea.View { } mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y). - Width(area.Dx()).Height(area.Dy()). - AddLayers( - lipgloss.NewLayer( - lipgloss.NewStyle().Width(chatRect.Dx()). - Height(chatRect.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Main View "), - ).X(chatRect.Min.X).Y(chatRect.Min.Y), - lipgloss.NewLayer(m.side.View()). - X(sideRect.Min.X).Y(sideRect.Min.Y), - lipgloss.NewLayer(m.textarea.View()). - X(editRect.Min.X).Y(editRect.Min.Y), - lipgloss.NewLayer(m.help.View(helpKeyMap)). - X(helpRect.Min.X).Y(helpRect.Min.Y), - ) + Width(area.Dx()).Height(area.Dy()) + + switch m.state { + case uiConfigure: + header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) + main := lipgloss.NewLayer( + lipgloss.NewStyle().Width(mainRect.Dx()). + Height(mainRect.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Configure "), + ).X(mainRect.Min.X).Y(mainRect.Min.Y) + mainLayer = mainLayer.AddLayers(header, main) + case uiInitialize: + header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) + main := lipgloss.NewLayer(m.initializeView()).X(mainRect.Min.X).Y(mainRect.Min.Y) + mainLayer = mainLayer.AddLayers(header, main) + case uiLanding: + header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) + main := lipgloss.NewLayer( + lipgloss.NewStyle().Width(mainRect.Dx()). + Height(mainRect.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Landing Page "), + ).X(mainRect.Min.X).Y(mainRect.Min.Y) + editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y) + mainLayer = mainLayer.AddLayers(header, main, editor) + case uiChat: + header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) + side := lipgloss.NewLayer(m.side.View()).X(sideRect.Min.X).Y(sideRect.Min.Y) + main := lipgloss.NewLayer( + lipgloss.NewStyle().Width(mainRect.Dx()). + Height(mainRect.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Chat Messages "), + ).X(mainRect.Min.X).Y(mainRect.Min.Y) + editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y) + mainLayer = mainLayer.AddLayers(header, main, side, editor) + case uiChatCompact: + header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) + main := lipgloss.NewLayer( + lipgloss.NewStyle().Width(mainRect.Dx()). + Height(mainRect.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Compact Chat Messages "), + ).X(mainRect.Min.X).Y(mainRect.Min.Y) + editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y) + mainLayer = mainLayer.AddLayers(header, main, editor) + } + + // Add help layer + help := lipgloss.NewLayer(m.help.View(helpKeyMap)).X(helpRect.Min.X).Y(helpRect.Min.Y) + mainLayer = mainLayer.AddLayers(help) layers = append(layers, mainLayer) + // Debugging rendering (visually see when the tui rerenders) + if os.Getenv("CRUSH_UI_DEBUG") == "true" { + content := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2) + debugLayer := lipgloss.NewLayer(content). + X(4). + Y(1) + layers = append(layers, debugLayer) + } + v.Content = lipgloss.NewCanvas(layers...) if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { // HACK: use a random percentage to prevent ghostty from hiding it @@ -248,37 +337,44 @@ func (m *UI) ShortHelp() []key.Binding { var binds []key.Binding k := &m.keyMap - if m.sess == nil { - // no session selected - binds = append(binds, - k.Commands, - k.Models, - k.Editor.Newline, - k.Quit, - k.Help, - ) - } else { - // we have a session - } + switch m.state { + case uiInitialize: + binds = append(binds, k.Quit) + default: + // TODO: other states + if m.sess == nil { + // no session selected + binds = append(binds, + k.Commands, + k.Models, + k.Editor.Newline, + k.Quit, + k.Help, + ) + } else { + // we have a session + } - // switch m.state { - // case uiChat: - // case uiEdit: - // binds = append(binds, - // k.Editor.AddFile, - // k.Editor.SendMessage, - // k.Editor.OpenEditor, - // k.Editor.Newline, - // ) - // - // if len(m.attachments) > 0 { - // binds = append(binds, - // k.Editor.AttachmentDeleteMode, - // k.Editor.DeleteAllAttachments, - // k.Editor.Escape, - // ) - // } - // } + // switch m.state { + // case uiChat: + // case uiEdit: + // binds = append(binds, + // k.Editor.AddFile, + // k.Editor.SendMessage, + // k.Editor.OpenEditor, + // k.Editor.Newline, + // ) + // + // if len(m.attachments) > 0 { + // binds = append(binds, + // k.Editor.AttachmentDeleteMode, + // k.Editor.DeleteAllAttachments, + // k.Editor.Escape, + // ) + // } + // } + + } return binds } @@ -290,26 +386,34 @@ func (m *UI) FullHelp() [][]key.Binding { help := k.Help help.SetHelp("ctrl+g", "less") - if m.sess == nil { - // no session selected + switch m.state { + case uiInitialize: binds = append(binds, []key.Binding{ - k.Commands, - k.Models, - k.Sessions, - }, - []key.Binding{ - k.Editor.Newline, - k.Editor.AddImage, - k.Editor.MentionFile, - k.Editor.OpenEditor, - }, - []key.Binding{ - help, - }, - ) - } else { - // we have a session + k.Quit, + }) + default: + if m.sess == nil { + // no session selected + binds = append(binds, + []key.Binding{ + k.Commands, + k.Models, + k.Sessions, + }, + []key.Binding{ + k.Editor.Newline, + k.Editor.AddImage, + k.Editor.MentionFile, + k.Editor.OpenEditor, + }, + []key.Binding{ + help, + }, + ) + } else { + // we have a session + } } // switch m.state { @@ -334,10 +438,10 @@ func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { // updateFocused updates the focused model (chat or editor) with the given message // and appends any resulting commands to the cmds slice. func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { - switch m.state { - case uiChat: + switch m.focus { + case uiFocusMain: m.updateChat(msg, cmds) - case uiEdit: + case uiFocusEditor: switch { case key.Matches(msg, m.keyMap.Editor.Newline): m.textarea.InsertRune('\n') @@ -366,8 +470,18 @@ func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { func (m *UI) updateLayoutAndSize(w, h int) { // The screen area we're working with area := image.Rect(0, 0, w, h) - var helpKeyMap help.KeyMap = m + + // The help height helpHeight := 1 + // The editor height + editorHeight := 5 + // The sidebar width + sidebarWidth := 40 + // The header height + // TODO: handle compact + headerHeight := 4 + + var helpKeyMap help.KeyMap = m if m.help.ShowAll { for _, row := range helpKeyMap.FullHelp() { helpHeight = max(helpHeight, len(row)) @@ -375,35 +489,103 @@ func (m *UI) updateLayoutAndSize(w, h int) { } // Add app margins - mainRect := area - mainRect.Min.X += 1 - mainRect.Min.Y += 1 - mainRect.Max.X -= 1 - mainRect.Max.Y -= 1 - - mainRect, helpRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-helpHeight)) - chatRect, sideRect := uv.SplitHorizontal(mainRect, uv.Fixed(mainRect.Dx()-40)) - chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(mainRect.Dy()-5)) + appRect := area + appRect.Min.X += 1 + appRect.Min.Y += 1 + appRect.Max.X -= 1 + appRect.Max.Y -= 1 + + if slices.Contains([]uiState{uiConfigure, uiInitialize}, m.state) { + // extra padding on left and right for these states + appRect.Min.X += 1 + appRect.Max.X -= 1 + } - // Add 1 line margin bottom of chatRect - chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1)) - // Add 1 line margin bottom of editRect - editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1)) + appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight)) m.layout = layout{ - area: area, - main: mainRect, - chat: chatRect, - editor: editRect, - sidebar: sideRect, - help: helpRect, + area: area, + help: helpRect, } - // Update sub-model sizes - m.side.SetWidth(m.layout.sidebar.Dx()) - m.textarea.SetWidth(m.layout.editor.Dx()) - m.textarea.SetHeight(m.layout.editor.Dy()) + // Set help width m.help.SetWidth(m.layout.help.Dx()) + + // Handle different app states + switch m.state { + case uiConfigure, uiInitialize: + // Layout + // + // header + // ------ + // main + // ------ + // help + + headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight)) + m.layout.header = headerRect + m.layout.main = mainRect + m.renderHeader(false, m.layout.header.Dx()) + + case uiLanding: + // Layout + // + // header + // ------ + // main + // ------ + // editor + // ------ + // help + headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight)) + mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + m.layout.header = headerRect + m.layout.main = mainRect + m.layout.editor = editorRect + // TODO: set the width and heigh of the chat component + m.renderHeader(false, m.layout.header.Dx()) + m.textarea.SetWidth(m.layout.editor.Dx()) + m.textarea.SetHeight(m.layout.editor.Dy()) + + case uiChat: + // Layout + // + // ------|--- + // main | + // ------| side + // editor| + // ---------- + // help + + mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth)) + mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + m.layout.sidebar = sideRect + m.layout.main = mainRect + m.layout.editor = editorRect + // TODO: set the width and heigh of the chat component + m.side.SetWidth(m.layout.sidebar.Dx()) + m.textarea.SetWidth(m.layout.editor.Dx()) + m.textarea.SetHeight(m.layout.editor.Dy()) + case uiChatCompact: + // Layout + // + // compact-header + // ------ + // main + // ------ + // editor + // ------ + // help + headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight)) + mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + m.layout.header = headerRect + m.layout.main = mainRect + m.layout.editor = editorRect + // TODO: set the width and heigh of the chat component + m.renderHeader(true, m.layout.header.Dx()) + m.textarea.SetWidth(m.layout.editor.Dx()) + m.textarea.SetHeight(m.layout.editor.Dy()) + } } // layout defines the positioning of UI elements. @@ -411,11 +593,14 @@ type layout struct { // area is the overall available area. area uv.Rectangle - // main is the main area excluding help. - main uv.Rectangle + // header is the header shown in special cases + // e.x when the sidebar is collapsed + // or when in the landing page + // or in init/config + header uv.Rectangle - // chat is the area for the chat pane. - chat uv.Rectangle + // main is the area for the main pane. (e.x chat, configure, landing) + main uv.Rectangle // editor is the area for the editor pane. editor uv.Rectangle @@ -481,3 +666,57 @@ func (m *UI) randomizePlaceholders() { m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] } + +func (m *UI) initializeView() string { + cfg := m.com.Config() + s := m.com.Styles.Initialize + cwd := home.Short(cfg.WorkingDir()) + initFile := cfg.Options.InitializeAs + + header := s.Header.Render("Would you like to initialize this project?") + path := s.Accent.PaddingLeft(2).Render(cwd) + desc := s.Content.Render(fmt.Sprintf("When I initialize your codebase I examine the project and put the result into an %s file which serves as general context.", initFile)) + hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".") + prompt := s.Content.Render("Would you like to initialize now?") + + buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{ + {Text: "Yep!", Selected: m.yesInitializeSelected}, + {Text: "Nope", Selected: !m.yesInitializeSelected}, + }, " ") + + // max width 60 so the text is compact + width := min(m.layout.main.Dx(), 60) + + return lipgloss.NewStyle(). + Width(width). + Height(m.layout.main.Dy()). + PaddingBottom(1). + AlignVertical(lipgloss.Bottom). + Render(strings.Join( + []string{ + header, + path, + desc, + hint, + prompt, + buttons, + }, + "\n\n", + )) +} + +func (m *UI) renderHeader(compact bool, width int) { + // TODO: handle the compact case differently + m.header = renderLogo(m.com.Styles, compact, width) +} + +func renderLogo(t *styles.Styles, compact bool, width int) string { + return logo.Render(version.Version, compact, logo.Opts{ + FieldColor: t.LogoFieldColor, + TitleColorA: t.LogoTitleColorA, + TitleColorB: t.LogoTitleColorB, + CharmColor: t.LogoCharmColor, + VersionColor: t.LogoVersionColor, + Width: max(0, width-2), + }) +} diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 56d5a90df32778f5868732c934e16df485a8ba16..da344b3a758b5b076ca5a19e89a702c3bcc4f383 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -98,8 +98,8 @@ type Styles struct { FilePicker filepicker.Styles // Buttons - ButtonSelected lipgloss.Style - ButtonUnselected lipgloss.Style + ButtonFocus lipgloss.Style + ButtonBlur lipgloss.Style // Borders BorderFocus lipgloss.Style @@ -113,6 +113,8 @@ type Styles struct { EditorPromptYoloDotsFocused lipgloss.Style EditorPromptYoloDotsBlurred lipgloss.Style + // Background + Background color.Color // Logo LogoFieldColor color.Color LogoTitleColorA color.Color @@ -123,6 +125,13 @@ type Styles struct { // Sidebar SidebarFull lipgloss.Style SidebarCompact lipgloss.Style + + // Initialize + Initialize struct { + Header lipgloss.Style + Content lipgloss.Style + Accent lipgloss.Style + } } func DefaultStyles() Styles { @@ -178,6 +187,8 @@ func DefaultStyles() Styles { s := Styles{} + s.Background = bgBase + s.TextInput = textinput.Styles{ Focused: textinput.StyleState{ Text: base, @@ -537,8 +548,8 @@ func DefaultStyles() Styles { s.EarlyStateMessage = s.Subtle.PaddingLeft(2) // Buttons - s.ButtonSelected = lipgloss.NewStyle().Foreground(white).Background(secondary) - s.ButtonUnselected = s.Base.Background(bgSubtle) + s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary) + s.ButtonBlur = s.Base.Background(bgSubtle) // Borders s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2) @@ -562,6 +573,10 @@ func DefaultStyles() Styles { s.SidebarFull = lipgloss.NewStyle().Padding(1, 1) s.SidebarCompact = s.SidebarFull.PaddingTop(0) + // Initialize + s.Initialize.Header = s.Base + s.Initialize.Content = s.Muted + s.Initialize.Accent = s.Base.Foreground(greenDark) return s } From 22040d2c844aaf87b3eae9817447dca2867d60bb Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 19 Nov 2025 17:50:22 -0500 Subject: [PATCH 014/335] feat(ui): implement tea.Layer Draw for UI model --- internal/ui/model/ui.go | 286 +++++++++++++++++++++++----------------- 1 file changed, 163 insertions(+), 123 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index fb443e9fc35a4ebb583e95ab8be07be19deab37a..ed89070ec9032d2909f9e410623054827ca2559c 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -22,6 +22,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/version" uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/ultraviolet/screen" ) // uiFocusState represents the current focus state of the UI. @@ -50,6 +51,11 @@ type UI struct { com *common.Common sess *session.Session + // The width and height of the terminal in cells. + width int + height int + layout layout + focus uiFocusState state uiState @@ -64,8 +70,6 @@ type UI struct { // header is the last cached header logo header string - layout layout - // sendProgressBar instructs the TUI to send progress bar updates to the // terminal. sendProgressBar bool @@ -154,7 +158,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case tea.WindowSizeMsg: - m.updateLayoutAndSize(msg.Width, msg.Height) + m.width, m.height = msg.Width, msg.Height + m.updateLayoutAndSize() case tea.KeyboardEnhancementsMsg: m.keyenh = msg if msg.SupportsKeyDisambiguation() { @@ -182,7 +187,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.keyMap.Help): m.help.ShowAll = !m.help.ShowAll - m.updateLayoutAndSize(m.layout.area.Dx(), m.layout.area.Dy()) + m.updateLayoutAndSize() case key.Matches(msg, m.keyMap.Quit): if !m.dialog.ContainsDialog(dialog.QuitDialogID) { m.dialog.AddDialog(dialog.NewQuit(m.com)) @@ -218,111 +223,118 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -// View renders the UI model's view. -func (m *UI) View() tea.View { - var v tea.View - v.AltScreen = true - v.BackgroundColor = m.com.Styles.Background +// Draw implements [tea.Layer] and draws the UI model. +func (m *UI) Draw(scr tea.Screen, area tea.Rectangle) { + layout := generateLayout(m, area.Dx(), area.Dy()) - layers := []*lipgloss.Layer{} + // Clear the screen first + screen.Clear(scr) - // Determine the help key map based on focus - var helpKeyMap help.KeyMap = m + switch m.state { + case uiConfigure: + header := uv.NewStyledString(m.header) + header.Draw(scr, layout.header) - // The screen areas we're working with - area := m.layout.area - headerRect := m.layout.header - mainRect := m.layout.main - sideRect := m.layout.sidebar - editRect := m.layout.editor - helpRect := m.layout.help + mainView := lipgloss.NewStyle().Width(layout.main.Dx()). + Height(layout.main.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Configure ") + main := uv.NewStyledString(mainView) + main.Draw(scr, layout.main) - if m.dialog.HasDialogs() { - if dialogView := m.dialog.View(); dialogView != "" { - dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView) - dialogArea := common.CenterRect(area, dialogWidth, dialogHeight) - layers = append(layers, - lipgloss.NewLayer(dialogView). - X(dialogArea.Min.X). - Y(dialogArea.Min.Y). - Z(99), - ) - } - } - - if m.focus == uiFocusEditor && m.textarea.Focused() { - cur := m.textarea.Cursor() - cur.X++ // Adjust for app margins - cur.Y += editRect.Min.Y - v.Cursor = cur - } + case uiInitialize: + header := uv.NewStyledString(m.header) + header.Draw(scr, layout.header) - mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y). - Width(area.Dx()).Height(area.Dy()) + main := uv.NewStyledString(m.initializeView()) + main.Draw(scr, layout.main) - switch m.state { - case uiConfigure: - header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) - main := lipgloss.NewLayer( - lipgloss.NewStyle().Width(mainRect.Dx()). - Height(mainRect.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Configure "), - ).X(mainRect.Min.X).Y(mainRect.Min.Y) - mainLayer = mainLayer.AddLayers(header, main) - case uiInitialize: - header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) - main := lipgloss.NewLayer(m.initializeView()).X(mainRect.Min.X).Y(mainRect.Min.Y) - mainLayer = mainLayer.AddLayers(header, main) case uiLanding: - header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) - main := lipgloss.NewLayer( - lipgloss.NewStyle().Width(mainRect.Dx()). - Height(mainRect.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Landing Page "), - ).X(mainRect.Min.X).Y(mainRect.Min.Y) - editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y) - mainLayer = mainLayer.AddLayers(header, main, editor) + header := uv.NewStyledString(m.header) + header.Draw(scr, layout.header) + + mainView := lipgloss.NewStyle().Width(layout.main.Dx()). + Height(layout.main.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Landing Page ") + main := uv.NewStyledString(mainView) + main.Draw(scr, layout.main) + + editor := uv.NewStyledString(m.textarea.View()) + editor.Draw(scr, layout.editor) + case uiChat: - header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) - side := lipgloss.NewLayer(m.side.View()).X(sideRect.Min.X).Y(sideRect.Min.Y) - main := lipgloss.NewLayer( - lipgloss.NewStyle().Width(mainRect.Dx()). - Height(mainRect.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Chat Messages "), - ).X(mainRect.Min.X).Y(mainRect.Min.Y) - editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y) - mainLayer = mainLayer.AddLayers(header, main, side, editor) + header := uv.NewStyledString(m.header) + header.Draw(scr, layout.header) + + side := uv.NewStyledString(m.side.View()) + side.Draw(scr, layout.sidebar) + + mainView := lipgloss.NewStyle().Width(layout.main.Dx()). + Height(layout.main.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Chat Messages ") + main := uv.NewStyledString(mainView) + main.Draw(scr, layout.main) + + editor := uv.NewStyledString(m.textarea.View()) + editor.Draw(scr, layout.editor) + case uiChatCompact: - header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) - main := lipgloss.NewLayer( - lipgloss.NewStyle().Width(mainRect.Dx()). - Height(mainRect.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Compact Chat Messages "), - ).X(mainRect.Min.X).Y(mainRect.Min.Y) - editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y) - mainLayer = mainLayer.AddLayers(header, main, editor) + header := uv.NewStyledString(m.header) + header.Draw(scr, layout.header) + + mainView := lipgloss.NewStyle().Width(layout.main.Dx()). + Height(layout.main.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Compact Chat Messages ") + main := uv.NewStyledString(mainView) + main.Draw(scr, layout.main) + + editor := uv.NewStyledString(m.textarea.View()) + editor.Draw(scr, layout.editor) } // Add help layer - help := lipgloss.NewLayer(m.help.View(helpKeyMap)).X(helpRect.Min.X).Y(helpRect.Min.Y) - mainLayer = mainLayer.AddLayers(help) - - layers = append(layers, mainLayer) + help := uv.NewStyledString(m.help.View(m)) + help.Draw(scr, layout.help) // Debugging rendering (visually see when the tui rerenders) if os.Getenv("CRUSH_UI_DEBUG") == "true" { - content := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2) - debugLayer := lipgloss.NewLayer(content). - X(4). - Y(1) - layers = append(layers, debugLayer) + debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2) + debug := uv.NewStyledString(debugView.String()) + debug.Draw(scr, image.Rectangle{ + Min: image.Pt(4, 1), + Max: image.Pt(8, 3), + }) } - v.Content = lipgloss.NewCanvas(layers...) + // This needs to come last to overlay on top of everything + if m.dialog.HasDialogs() { + if dialogView := m.dialog.View(); dialogView != "" { + dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView) + dialogArea := common.CenterRect(area, dialogWidth, dialogHeight) + dialog := uv.NewStyledString(dialogView) + dialog.Draw(scr, dialogArea) + } + } +} + +// View renders the UI model's view. +func (m *UI) View() tea.View { + var v tea.View + v.AltScreen = true + v.BackgroundColor = m.com.Styles.Background + + layout := generateLayout(m, m.width, m.height) + if m.focus == uiFocusEditor && m.textarea.Focused() { + cur := m.textarea.Cursor() + cur.X++ // Adjust for app margins + cur.Y += layout.editor.Min.Y + v.Cursor = cur + } + + v.Content = m if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { // HACK: use a random percentage to prevent ghostty from hiding it // after a timeout. @@ -465,9 +477,44 @@ func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { } } -// updateLayoutAndSize updates the layout and sub-models sizes based on the -// given terminal width and height given in cells. -func (m *UI) updateLayoutAndSize(w, h int) { +// updateLayoutAndSize updates the layout and sizes of UI components. +func (m *UI) updateLayoutAndSize() { + m.layout = generateLayout(m, m.width, m.height) + m.updateSize() +} + +// updateSize updates the sizes of UI components based on the current layout. +func (m *UI) updateSize() { + // Set help width + m.help.SetWidth(m.layout.help.Dx()) + + // Handle different app states + switch m.state { + case uiConfigure, uiInitialize: + m.renderHeader(false, m.layout.header.Dx()) + + case uiLanding: + // TODO: set the width and heigh of the chat component + m.renderHeader(false, m.layout.header.Dx()) + m.textarea.SetWidth(m.layout.editor.Dx()) + m.textarea.SetHeight(m.layout.editor.Dy()) + + case uiChat: + // TODO: set the width and heigh of the chat component + m.side.SetWidth(m.layout.sidebar.Dx()) + m.textarea.SetWidth(m.layout.editor.Dx()) + m.textarea.SetHeight(m.layout.editor.Dy()) + + case uiChatCompact: + // TODO: set the width and heigh of the chat component + m.renderHeader(true, m.layout.header.Dx()) + m.textarea.SetWidth(m.layout.editor.Dx()) + m.textarea.SetHeight(m.layout.editor.Dy()) + } +} + +// generateLayout generates a [layout] for the given rectangle. +func generateLayout(m *UI, w, h int) layout { // The screen area we're working with area := image.Rect(0, 0, w, h) @@ -503,14 +550,11 @@ func (m *UI) updateLayoutAndSize(w, h int) { appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight)) - m.layout = layout{ + layout := layout{ area: area, help: helpRect, } - // Set help width - m.help.SetWidth(m.layout.help.Dx()) - // Handle different app states switch m.state { case uiConfigure, uiInitialize: @@ -523,9 +567,8 @@ func (m *UI) updateLayoutAndSize(w, h int) { // help headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight)) - m.layout.header = headerRect - m.layout.main = mainRect - m.renderHeader(false, m.layout.header.Dx()) + layout.header = headerRect + layout.main = mainRect case uiLanding: // Layout @@ -539,13 +582,9 @@ func (m *UI) updateLayoutAndSize(w, h int) { // help headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight)) mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) - m.layout.header = headerRect - m.layout.main = mainRect - m.layout.editor = editorRect - // TODO: set the width and heigh of the chat component - m.renderHeader(false, m.layout.header.Dx()) - m.textarea.SetWidth(m.layout.editor.Dx()) - m.textarea.SetHeight(m.layout.editor.Dy()) + layout.header = headerRect + layout.main = mainRect + layout.editor = editorRect case uiChat: // Layout @@ -559,13 +598,10 @@ func (m *UI) updateLayoutAndSize(w, h int) { mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth)) mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) - m.layout.sidebar = sideRect - m.layout.main = mainRect - m.layout.editor = editorRect - // TODO: set the width and heigh of the chat component - m.side.SetWidth(m.layout.sidebar.Dx()) - m.textarea.SetWidth(m.layout.editor.Dx()) - m.textarea.SetHeight(m.layout.editor.Dy()) + layout.sidebar = sideRect + layout.main = mainRect + layout.editor = editorRect + case uiChatCompact: // Layout // @@ -578,14 +614,18 @@ func (m *UI) updateLayoutAndSize(w, h int) { // help headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight)) mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) - m.layout.header = headerRect - m.layout.main = mainRect - m.layout.editor = editorRect - // TODO: set the width and heigh of the chat component - m.renderHeader(true, m.layout.header.Dx()) - m.textarea.SetWidth(m.layout.editor.Dx()) - m.textarea.SetHeight(m.layout.editor.Dy()) + layout.header = headerRect + layout.main = mainRect + layout.editor = editorRect } + + if !layout.editor.Empty() { + // Add editor margins 1 top and bottom + layout.editor.Min.Y += 1 + layout.editor.Max.Y -= 1 + } + + return layout } // layout defines the positioning of UI elements. From 4c223c375378ed805f04692e06f22e84646929a8 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 24 Nov 2025 14:07:45 -0500 Subject: [PATCH 015/335] fix: use canvas to render UI view --- internal/ui/model/ui.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index ed89070ec9032d2909f9e410623054827ca2559c..7b53610ac9d0ff381154faa679867a279b2a62aa 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -23,6 +23,7 @@ import ( "github.com/charmbracelet/crush/internal/version" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/ultraviolet/screen" + "github.com/charmbracelet/x/ansi" ) // uiFocusState represents the current focus state of the UI. @@ -224,7 +225,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Draw implements [tea.Layer] and draws the UI model. -func (m *UI) Draw(scr tea.Screen, area tea.Rectangle) { +func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { layout := generateLayout(m, area.Dx(), area.Dy()) // Clear the screen first @@ -334,7 +335,21 @@ func (m *UI) View() tea.View { v.Cursor = cur } - v.Content = m + // TODO: Switch to lipgloss.Canvas when available + canvas := uv.NewScreenBuffer(m.width, m.height) + canvas.Method = ansi.GraphemeWidth + + m.Draw(canvas, canvas.Bounds()) + + content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines + contentLines := strings.Split(content, "\n") + for i, line := range contentLines { + // Trim trailing spaces for concise rendering + contentLines[i] = strings.TrimRight(line, " ") + } + + content = strings.Join(contentLines, "\n") + v.Content = content if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { // HACK: use a random percentage to prevent ghostty from hiding it // after a timeout. From db5c3eb3983e838eec277e959e42c6fa18f4272a Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 25 Nov 2025 09:28:37 +0100 Subject: [PATCH 016/335] chore: initialize functionality & landing page --- internal/ui/common/elements.go | 121 ++++++++++++++++ internal/ui/model/keys.go | 5 + internal/ui/model/landing.go | 71 ++++++++++ internal/ui/model/lsp.go | 113 +++++++++++++++ internal/ui/model/mcp.go | 88 ++++++++++++ internal/ui/model/onboarding.go | 96 +++++++++++++ internal/ui/model/ui.go | 239 +++++++++++++++----------------- internal/ui/styles/styles.go | 26 +++- 8 files changed, 628 insertions(+), 131 deletions(-) create mode 100644 internal/ui/common/elements.go create mode 100644 internal/ui/model/landing.go create mode 100644 internal/ui/model/lsp.go create mode 100644 internal/ui/model/mcp.go create mode 100644 internal/ui/model/onboarding.go diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go new file mode 100644 index 0000000000000000000000000000000000000000..762fac8e12ebad622c4df4e16c4ebd12ad0f6613 --- /dev/null +++ b/internal/ui/common/elements.go @@ -0,0 +1,121 @@ +package common + +import ( + "cmp" + "fmt" + "image/color" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/home" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +func PrettyPath(t *styles.Styles, path string, width int) string { + formatted := home.Short(path) + return t.Muted.Width(width).Render(formatted) +} + +type ModelContextInfo struct { + ContextUsed int64 + ModelContext int64 + Cost float64 +} + +func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context *ModelContextInfo, width int) string { + modelIcon := t.Subtle.Render(styles.ModelIcon) + modelName = t.Base.Render(modelName) + modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName) + + parts := []string{ + modelInfo, + } + + if reasoningInfo != "" { + parts = append(parts, t.Subtle.PaddingLeft(2).Render(reasoningInfo)) + } + + if context != nil { + parts = append(parts, formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost)) + } + + return lipgloss.NewStyle().Width(width).Render( + lipgloss.JoinVertical(lipgloss.Left, parts...), + ) +} + +func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string { + 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) + } + + 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 + + formattedCost := t.Muted.Render(fmt.Sprintf("$%.2f", cost)) + + formattedTokens = t.Subtle.Render(fmt.Sprintf("(%s)", formattedTokens)) + 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) + } + + return fmt.Sprintf("%s %s", formattedTokens, formattedCost) +} + +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(t *styles.Styles, opts StatusOpts, width int) string { + icon := opts.Icon + title := opts.Title + description := opts.Description + + titleColor := cmp.Or(opts.TitleColor, t.Muted.GetForeground()) + descriptionColor := cmp.Or(opts.DescriptionColor, t.Subtle.GetForeground()) + + title = t.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.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, " ") +} diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index cf5d2721372f5a7d49de85d6b2155d8dfb24e4af..4acf010512b64de707eb6716ba574c885604276d 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -20,6 +20,7 @@ type KeyMap struct { Initialize struct { Yes, No, + Enter, Switch key.Binding } @@ -117,6 +118,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("left", "right", "tab"), key.WithHelp("tab", "switch"), ) + km.Initialize.Enter = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ) return km } diff --git a/internal/ui/model/landing.go b/internal/ui/model/landing.go new file mode 100644 index 0000000000000000000000000000000000000000..dda843250db29a19626cd7dae5e67990bd85c1ad --- /dev/null +++ b/internal/ui/model/landing.go @@ -0,0 +1,71 @@ +package model + +import ( + "cmp" + "fmt" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/ui/common" + uv "github.com/charmbracelet/ultraviolet" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +func (m *UI) selectedLargeModel() *agent.Model { + if m.com.App.AgentCoordinator != nil { + model := m.com.App.AgentCoordinator.Model() + return &model + } + return nil +} + +func (m *UI) landingView() string { + t := m.com.Styles + width := m.layout.main.Dx() + cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width) + + parts := []string{ + cwd, + } + + model := m.selectedLargeModel() + if model != nil && model.CatwalkCfg.CanReason { + reasoningInfo := "" + providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider) + if ok { + switch providerConfig.Type { + case catwalk.TypeAnthropic: + if model.ModelCfg.Think { + reasoningInfo = "Thinking On" + } else { + reasoningInfo = "Thinking Off" + } + default: + formatter := cases.Title(language.English, cases.NoLower) + reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort) + reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort)) + } + parts = append(parts, "", common.ModelInfo(t, model.CatwalkCfg.Name, reasoningInfo, nil, width)) + } + } + infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...) + + _, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1)) + + mcpLspSectionWidth := min(30, (width-1)/2) + + lspSection := m.lspInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy()) + mcpSection := m.mcpInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy()) + + content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection) + + return lipgloss.NewStyle(). + Width(width). + Height(m.layout.main.Dy() - 1). + PaddingTop(1). + Render( + lipgloss.JoinVertical(lipgloss.Left, infoSection, "", content), + ) +} diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go new file mode 100644 index 0000000000000000000000000000000000000000..220133b793da374920c7bd0fc2ee9e0aecd3b67b --- /dev/null +++ b/internal/ui/model/lsp.go @@ -0,0 +1,113 @@ +package model + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" +) + +type LSPInfo struct { + app.LSPClientInfo + Diagnostics map[protocol.DiagnosticSeverity]int +} + +func (m *UI) lspInfo(t *styles.Styles, width, height int) string { + var lsps []LSPInfo + + for _, state := range m.lspStates { + client, ok := m.com.App.LSPClients.Get(state.Name) + if !ok { + continue + } + lspErrs := map[protocol.DiagnosticSeverity]int{ + protocol.SeverityError: 0, + protocol.SeverityWarning: 0, + protocol.SeverityHint: 0, + protocol.SeverityInformation: 0, + } + + for _, diagnostics := range client.GetDiagnostics() { + for _, diagnostic := range diagnostics { + if severity, ok := lspErrs[diagnostic.Severity]; ok { + lspErrs[diagnostic.Severity] = severity + 1 + } + } + } + + lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs}) + } + title := t.Subtle.Render("LSPs") + list := t.Subtle.Render("None") + if len(lsps) > 0 { + height = max(0, height-2) // remove title and space + list = lspList(t, lsps, width, height) + } + + return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) +} + +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]))) + } + if diagnostics[protocol.SeverityWarning] > 0 { + errs = append(errs, t.LSP.WarningDiagnostic.Render(fmt.Sprintf("%s %d", styles.WarningIcon, diagnostics[protocol.SeverityWarning]))) + } + if diagnostics[protocol.SeverityHint] > 0 { + errs = append(errs, t.LSP.HintDiagnostic.Render(fmt.Sprintf("%s %d", styles.HintIcon, diagnostics[protocol.SeverityHint]))) + } + if diagnostics[protocol.SeverityInformation] > 0 { + errs = append(errs, t.LSP.InfoDiagnostic.Render(fmt.Sprintf("%s %d", styles.InfoIcon, diagnostics[protocol.SeverityInformation]))) + } + return strings.Join(errs, " ") +} + +func lspList(t *styles.Styles, lsps []LSPInfo, width, height int) string { + var renderedLsps []string + for _, l := range lsps { + var icon string + title := l.Name + var description string + var diagnostics string + switch l.State { + case lsp.StateStarting: + icon = t.ItemBusyIcon.String() + description = t.Subtle.Render("starting...") + case lsp.StateReady: + icon = t.ItemOnlineIcon.String() + diagnostics = lspDiagnostics(t, l.Diagnostics) + case lsp.StateError: + icon = t.ItemErrorIcon.String() + description = t.Subtle.Render("error") + if l.Error != nil { + description = t.Subtle.Render(fmt.Sprintf("error: %s", l.Error.Error())) + } + case lsp.StateDisabled: + icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() + description = t.Subtle.Render("inactive") + default: + icon = t.ItemOfflineIcon.String() + } + renderedLsps = append(renderedLsps, common.Status(t, common.StatusOpts{ + Icon: icon, + Title: title, + Description: description, + ExtraContent: diagnostics, + }, width)) + } + + if len(renderedLsps) > height { + visibleItems := renderedLsps[:height-1] + remaining := len(renderedLsps) - (height - 1) + visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) + return lipgloss.JoinVertical(lipgloss.Left, visibleItems...) + } + return lipgloss.JoinVertical(lipgloss.Left, renderedLsps...) +} diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..f4278a1e45afac2a7f8d12b495e1eb0be7cecfa5 --- /dev/null +++ b/internal/ui/model/mcp.go @@ -0,0 +1,88 @@ +package model + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/tools/mcp" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +type MCPInfo struct { + mcp.ClientInfo +} + +func (m *UI) mcpInfo(t *styles.Styles, width, height int) string { + var mcps []MCPInfo + + for _, state := range m.mcpStates { + mcps = append(mcps, MCPInfo{ClientInfo: state}) + } + + title := t.Subtle.Render("MCPs") + list := t.Subtle.Render("None") + if len(mcps) > 0 { + height = max(0, height-2) // remove title and space + list = mcpList(t, mcps, width, height) + } + + return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) +} + +func mcpCounts(t *styles.Styles, counts mcp.Counts) string { + parts := []string{} + if counts.Tools > 0 { + parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d tools", counts.Tools))) + } + if counts.Prompts > 0 { + parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d prompts", counts.Prompts))) + } + return strings.Join(parts, " ") +} + +func mcpList(t *styles.Styles, mcps []MCPInfo, width, height int) string { + var renderedMcps []string + for _, m := range mcps { + var icon string + title := m.Name + var description string + var extraContent string + + switch m.State { + case mcp.StateStarting: + icon = t.ItemBusyIcon.String() + description = t.Subtle.Render("starting...") + case mcp.StateConnected: + icon = t.ItemOnlineIcon.String() + extraContent = mcpCounts(t, m.Counts) + case mcp.StateError: + icon = t.ItemErrorIcon.String() + description = t.Subtle.Render("error") + if m.Error != nil { + description = t.Subtle.Render(fmt.Sprintf("error: %s", m.Error.Error())) + } + case mcp.StateDisabled: + icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() + description = t.Subtle.Render("disabled") + default: + icon = t.ItemOfflineIcon.String() + } + + renderedMcps = append(renderedMcps, common.Status(t, common.StatusOpts{ + Icon: icon, + Title: title, + Description: description, + ExtraContent: extraContent, + }, width)) + } + + if len(renderedMcps) > height { + visibleItems := renderedMcps[:height-1] + remaining := len(renderedMcps) - (height - 1) + visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) + return lipgloss.JoinVertical(lipgloss.Left, visibleItems...) + } + return lipgloss.JoinVertical(lipgloss.Left, renderedMcps...) +} diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go new file mode 100644 index 0000000000000000000000000000000000000000..2f50ae85d1086dd00674e87560b44a4ae2aad202 --- /dev/null +++ b/internal/ui/model/onboarding.go @@ -0,0 +1,96 @@ +package model + +import ( + "fmt" + "log/slog" + "strings" + + "charm.land/bubbles/v2/key" + 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/ui/common" +) + +func (m *UI) markProjectInitialized() tea.Msg { + // TODO: handle error so we show it in the tui footer + err := config.MarkProjectInitialized() + if err != nil { + slog.Error(err.Error()) + } + return nil +} + +func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) { + switch { + case key.Matches(msg, m.keyMap.Initialize.Enter): + if m.onboarding.yesInitializeSelected { + cmds = append(cmds, m.initializeProject()) + } else { + cmds = append(cmds, m.skipInitializeProject()) + } + case key.Matches(msg, m.keyMap.Initialize.Switch): + m.onboarding.yesInitializeSelected = !m.onboarding.yesInitializeSelected + case key.Matches(msg, m.keyMap.Initialize.Yes): + cmds = append(cmds, m.initializeProject()) + case key.Matches(msg, m.keyMap.Initialize.No): + cmds = append(cmds, m.skipInitializeProject()) + } + return cmds +} + +func (m *UI) initializeProject() tea.Cmd { + // TODO: initialize the project + // for now we just go to the landing page + m.state = uiLanding + m.focus = uiFocusEditor + // TODO: actually send a message to the agent + return m.markProjectInitialized +} + +func (m *UI) skipInitializeProject() tea.Cmd { + // TODO: initialize the project + m.state = uiLanding + m.focus = uiFocusEditor + // mark the project as initialized + return m.markProjectInitialized +} + +func (m *UI) initializeView() string { + cfg := m.com.Config() + s := m.com.Styles.Initialize + cwd := home.Short(cfg.WorkingDir()) + initFile := cfg.Options.InitializeAs + + header := s.Header.Render("Would you like to initialize this project?") + path := s.Accent.PaddingLeft(2).Render(cwd) + desc := s.Content.Render(fmt.Sprintf("When I initialize your codebase I examine the project and put the result into an %s file which serves as general context.", initFile)) + hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".") + prompt := s.Content.Render("Would you like to initialize now?") + + buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{ + {Text: "Yep!", Selected: m.onboarding.yesInitializeSelected}, + {Text: "Nope", Selected: !m.onboarding.yesInitializeSelected}, + }, " ") + + // max width 60 so the text is compact + width := min(m.layout.main.Dx(), 60) + + return lipgloss.NewStyle(). + Width(width). + Height(m.layout.main.Dy()). + PaddingBottom(1). + AlignVertical(lipgloss.Bottom). + Render(strings.Join( + []string{ + header, + path, + desc, + hint, + prompt, + buttons, + }, + "\n\n", + )) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 7b53610ac9d0ff381154faa679867a279b2a62aa..cfa699b87f7c8d38d07410ff06b45e7958a3ac07 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1,7 +1,6 @@ package model import ( - "fmt" "image" "math/rand" "os" @@ -13,8 +12,10 @@ import ( "charm.land/bubbles/v2/textarea" 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/home" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" @@ -87,8 +88,16 @@ type UI struct { readyPlaceholder string workingPlaceholder string - // Initialize state - yesInitializeSelected bool + // onboarding state + onboarding struct { + yesInitializeSelected bool + } + + // lsp + lspStates map[string]app.LSPClientInfo + + // mcp + mcpStates map[string]mcp.ClientInfo } // New creates a new instance of the [UI] model. @@ -110,10 +119,11 @@ func New(com *common.Common) *UI { focus: uiFocusNone, state: uiConfigure, textarea: ta, - - // initialize - yesInitializeSelected: true, } + + // set onboarding state defaults + ui.onboarding.yesInitializeSelected = true + // If no provider is configured show the user the provider list if !com.Config().IsConfigured() { ui.state = uiConfigure @@ -129,6 +139,7 @@ func New(com *common.Common) *UI { ui.setEditorPrompt() ui.randomizePlaceholders() ui.textarea.Placeholder = ui.readyPlaceholder + ui.help.Styles = com.Styles.Help return ui } @@ -144,13 +155,16 @@ func (m *UI) Init() tea.Cmd { // Update handles updates to the UI model. func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - hasDialogs := m.dialog.HasDialogs() switch msg := msg.(type) { case tea.EnvMsg: // Is this Windows Terminal? if !m.sendProgressBar { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } + case pubsub.Event[app.LSPEvent]: + m.lspStates = app.GetLSPStates() + case pubsub.Event[mcp.Event]: + m.mcpStates = mcp.GetStates() case tea.TerminalVersionMsg: termVersion := strings.ToLower(msg.Name) // Only enable progress bar for the following terminals. @@ -168,60 +182,67 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline") } case tea.KeyPressMsg: - if hasDialogs { - m.updateDialogs(msg, &cmds) - } + cmds = append(cmds, m.handleKeyPressMsg(msg)...) } - if !hasDialogs { - // This branch only handles UI elements when there's no dialog shown. - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch { - case key.Matches(msg, m.keyMap.Tab): - if m.focus == uiFocusMain { - m.focus = uiFocusEditor - cmds = append(cmds, m.textarea.Focus()) - } else { - m.focus = uiFocusMain - m.textarea.Blur() - } - case key.Matches(msg, m.keyMap.Help): - m.help.ShowAll = !m.help.ShowAll - m.updateLayoutAndSize() - case key.Matches(msg, m.keyMap.Quit): - if !m.dialog.ContainsDialog(dialog.QuitDialogID) { - m.dialog.AddDialog(dialog.NewQuit(m.com)) - return m, nil - } - case key.Matches(msg, m.keyMap.Commands): - // TODO: Implement me - case key.Matches(msg, m.keyMap.Models): - // TODO: Implement me - case key.Matches(msg, m.keyMap.Sessions): - // TODO: Implement me - default: - m.updateFocused(msg, &cmds) - } + // This logic gets triggered on any message type, but should it? + switch m.focus { + case uiFocusMain: + case uiFocusEditor: + // Textarea placeholder logic + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + m.textarea.Placeholder = m.workingPlaceholder + } else { + m.textarea.Placeholder = m.readyPlaceholder + } + if m.com.App.Permissions.SkipRequests() { + m.textarea.Placeholder = "Yolo mode!" } + } - // This logic gets triggered on any message type, but should it? - switch m.focus { - case uiFocusMain: - case uiFocusEditor: - // Textarea placeholder logic - if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { - m.textarea.Placeholder = m.workingPlaceholder + return m, tea.Batch(cmds...) +} + +func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { + if m.dialog.HasDialogs() { + return m.updateDialogs(msg) + } + + switch { + case key.Matches(msg, m.keyMap.Tab): + switch m.state { + case uiChat: + if m.focus == uiFocusMain { + m.focus = uiFocusEditor + cmds = append(cmds, m.textarea.Focus()) } else { - m.textarea.Placeholder = m.readyPlaceholder - } - if m.com.App.Permissions.SkipRequests() { - m.textarea.Placeholder = "Yolo mode!" + m.focus = uiFocusMain + m.textarea.Blur() } } + case key.Matches(msg, m.keyMap.Help): + m.help.ShowAll = !m.help.ShowAll + m.updateLayoutAndSize() + return cmds + case key.Matches(msg, m.keyMap.Quit): + if !m.dialog.ContainsDialog(dialog.QuitDialogID) { + m.dialog.AddDialog(dialog.NewQuit(m.com)) + return + } + return cmds + case key.Matches(msg, m.keyMap.Commands): + // TODO: Implement me + return cmds + case key.Matches(msg, m.keyMap.Models): + // TODO: Implement me + return cmds + case key.Matches(msg, m.keyMap.Sessions): + // TODO: Implement me + return cmds } - return m, tea.Batch(cmds...) + cmds = append(cmds, m.updateFocused(msg)...) + return cmds } // Draw implements [tea.Layer] and draws the UI model. @@ -253,12 +274,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { case uiLanding: header := uv.NewStyledString(m.header) header.Draw(scr, layout.header) - - mainView := lipgloss.NewStyle().Width(layout.main.Dx()). - Height(layout.main.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Landing Page ") - main := uv.NewStyledString(mainView) + main := uv.NewStyledString(m.landingView()) main.Draw(scr, layout.main) editor := uv.NewStyledString(m.textarea.View()) @@ -378,9 +394,10 @@ func (m *UI) ShortHelp() []key.Binding { k.Quit, k.Help, ) - } else { - // we have a session } + // else { + // we have a session + // } // switch m.state { // case uiChat: @@ -400,7 +417,6 @@ func (m *UI) ShortHelp() []key.Binding { // ) // } // } - } return binds @@ -438,9 +454,10 @@ func (m *UI) FullHelp() [][]key.Binding { help, }, ) - } else { - // we have a session } + // else { + // we have a session + // } } // switch m.state { @@ -452,44 +469,48 @@ func (m *UI) FullHelp() [][]key.Binding { return binds } -// updateDialogs updates the dialog overlay with the given message and appends -// any resulting commands to the cmds slice. -func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { +// updateDialogs updates the dialog overlay with the given message and returns cmds +func (m *UI) updateDialogs(msg tea.KeyPressMsg) (cmds []tea.Cmd) { updatedDialog, cmd := m.dialog.Update(msg) m.dialog = updatedDialog - if cmd != nil { - *cmds = append(*cmds, cmd) - } + cmds = append(cmds, cmd) + return cmds } // updateFocused updates the focused model (chat or editor) with the given message // and appends any resulting commands to the cmds slice. -func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { - switch m.focus { - case uiFocusMain: - m.updateChat(msg, cmds) - case uiFocusEditor: - switch { - case key.Matches(msg, m.keyMap.Editor.Newline): - m.textarea.InsertRune('\n') - } +func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) { + switch m.state { + case uiConfigure: + return cmds + case uiInitialize: + return append(cmds, m.updateInitializeView(msg)...) + case uiChat, uiLanding, uiChatCompact: + switch m.focus { + case uiFocusMain: + cmds = append(cmds, m.updateChat(msg)...) + case uiFocusEditor: + switch { + case key.Matches(msg, m.keyMap.Editor.Newline): + m.textarea.InsertRune('\n') + } - ta, cmd := m.textarea.Update(msg) - m.textarea = ta - if cmd != nil { - *cmds = append(*cmds, cmd) + ta, cmd := m.textarea.Update(msg) + m.textarea = ta + cmds = append(cmds, cmd) + return cmds } } + return cmds } // updateChat updates the chat model with the given message and appends any // resulting commands to the cmds slice. -func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { +func (m *UI) updateChat(msg tea.KeyPressMsg) (cmds []tea.Cmd) { updatedChat, cmd := m.chat.Update(msg) m.chat = updatedChat - if cmd != nil { - *cmds = append(*cmds, cmd) - } + cmds = append(cmds, cmd) + return cmds } // updateLayoutAndSize updates the layout and sizes of UI components. @@ -509,7 +530,6 @@ func (m *UI) updateSize() { m.renderHeader(false, m.layout.header.Dx()) case uiLanding: - // TODO: set the width and heigh of the chat component m.renderHeader(false, m.layout.header.Dx()) m.textarea.SetWidth(m.layout.editor.Dx()) m.textarea.SetHeight(m.layout.editor.Dy()) @@ -557,7 +577,7 @@ func generateLayout(m *UI, w, h int) layout { appRect.Max.X -= 1 appRect.Max.Y -= 1 - if slices.Contains([]uiState{uiConfigure, uiInitialize}, m.state) { + if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) { // extra padding on left and right for these states appRect.Min.X += 1 appRect.Max.X -= 1 @@ -597,6 +617,9 @@ func generateLayout(m *UI, w, h int) layout { // help headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight)) mainRect, editorRect := uv.SplitVertical(mainRect, uv.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 @@ -722,44 +745,6 @@ func (m *UI) randomizePlaceholders() { m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] } -func (m *UI) initializeView() string { - cfg := m.com.Config() - s := m.com.Styles.Initialize - cwd := home.Short(cfg.WorkingDir()) - initFile := cfg.Options.InitializeAs - - header := s.Header.Render("Would you like to initialize this project?") - path := s.Accent.PaddingLeft(2).Render(cwd) - desc := s.Content.Render(fmt.Sprintf("When I initialize your codebase I examine the project and put the result into an %s file which serves as general context.", initFile)) - hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".") - prompt := s.Content.Render("Would you like to initialize now?") - - buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{ - {Text: "Yep!", Selected: m.yesInitializeSelected}, - {Text: "Nope", Selected: !m.yesInitializeSelected}, - }, " ") - - // max width 60 so the text is compact - width := min(m.layout.main.Dx(), 60) - - return lipgloss.NewStyle(). - Width(width). - Height(m.layout.main.Dy()). - PaddingBottom(1). - AlignVertical(lipgloss.Bottom). - Render(strings.Join( - []string{ - header, - path, - desc, - hint, - prompt, - buttons, - }, - "\n\n", - )) -} - func (m *UI) renderHeader(compact bool, width int) { // TODO: handle the compact case differently m.header = renderLogo(m.com.Styles, compact, width) diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index da344b3a758b5b076ca5a19e89a702c3bcc4f383..0b005f0b83690b53ead7951c0a0979cf83c7a073 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -132,6 +132,14 @@ type Styles struct { Content lipgloss.Style Accent lipgloss.Style } + + // LSP + LSP struct { + ErrorDiagnostic lipgloss.Style + WarningDiagnostic lipgloss.Style + HintDiagnostic lipgloss.Style + InfoDiagnostic lipgloss.Style + } } func DefaultStyles() Styles { @@ -159,10 +167,8 @@ func DefaultStyles() Styles { borderFocus = charmtone.Charple // Status - // success = charmtone.Guac - // error = charmtone.Sriracha - // warning = charmtone.Zest - // info = charmtone.Malibu + warning = charmtone.Zest + info = charmtone.Malibu // Colors white = charmtone.Butter @@ -577,6 +583,18 @@ func DefaultStyles() Styles { s.Initialize.Header = s.Base s.Initialize.Content = s.Muted s.Initialize.Accent = s.Base.Foreground(greenDark) + + // LSP and MCP status. + s.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●") + s.ItemBusyIcon = s.ItemOfflineIcon.Foreground(charmtone.Citron) + s.ItemErrorIcon = s.ItemOfflineIcon.Foreground(charmtone.Coral) + s.ItemOnlineIcon = s.ItemOfflineIcon.Foreground(charmtone.Guac) + + // LSP + s.LSP.ErrorDiagnostic = s.Base.Foreground(redDark) + s.LSP.WarningDiagnostic = s.Base.Foreground(warning) + s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted) + s.LSP.InfoDiagnostic = s.Base.Foreground(info) return s } From 26edcdc405cea27dac32208f060a36d565930c0a Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 26 Nov 2025 16:02:18 +0100 Subject: [PATCH 017/335] chore: add chat sidebar (#1510) --- internal/ui/common/elements.go | 27 +++- internal/ui/model/files.go | 211 ++++++++++++++++++++++++++++++++ internal/ui/model/landing.go | 35 ++---- internal/ui/model/lsp.go | 23 ++-- internal/ui/model/mcp.go | 31 +++-- internal/ui/model/onboarding.go | 5 + internal/ui/model/sidebar.go | 169 ++++++++++++++++++------- internal/ui/model/ui.go | 97 +++++++++++---- internal/ui/styles/styles.go | 27 +++- 9 files changed, 502 insertions(+), 123 deletions(-) create mode 100644 internal/ui/model/files.go diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index 762fac8e12ebad622c4df4e16c4ebd12ad0f6613..246543078028f5ab616c88c5b1b75103489e1d1a 100644 --- a/internal/ui/common/elements.go +++ b/internal/ui/common/elements.go @@ -12,17 +12,22 @@ import ( "github.com/charmbracelet/x/ansi" ) +// PrettyPath formats a file path with home directory shortening and applies +// muted styling. func PrettyPath(t *styles.Styles, path string, width int) string { formatted := home.Short(path) return t.Muted.Width(width).Render(formatted) } +// ModelContextInfo contains token usage and cost information for a model. type ModelContextInfo struct { ContextUsed int64 ModelContext int64 Cost float64 } +// ModelInfo renders model information including name, reasoning settings, and +// optional context usage/cost. func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context *ModelContextInfo, width int) string { modelIcon := t.Subtle.Render(styles.ModelIcon) modelName = t.Base.Render(modelName) @@ -37,7 +42,8 @@ func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context } if context != nil { - parts = append(parts, formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost)) + formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost) + parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo)) } return lipgloss.NewStyle().Width(width).Render( @@ -45,6 +51,8 @@ func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context ) } +// formatTokensAndCost formats token usage and cost with appropriate units +// (K/M) and percentage of context window. func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string { var formattedTokens string switch { @@ -77,6 +85,8 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo return fmt.Sprintf("%s %s", formattedTokens, formattedCost) } +// StatusOpts defines options for rendering a status line with icon, title, +// description, and optional extra content. type StatusOpts struct { Icon string // if empty no icon will be shown Title string @@ -86,6 +96,8 @@ type StatusOpts struct { ExtraContent string // additional content to append after the description } +// Status renders a status line with icon, title, description, and extra +// content. The description is truncated if it exceeds the available width. func Status(t *styles.Styles, opts StatusOpts, width int) string { icon := opts.Icon title := opts.Title @@ -119,3 +131,16 @@ func Status(t *styles.Styles, opts StatusOpts, width int) string { return strings.Join(content, " ") } + +// Section renders a section header with a title and a horizontal line filling +// the remaining width. +func Section(t *styles.Styles, text string, width int) string { + char := styles.SectionSeparator + length := lipgloss.Width(text) + 1 + remainingWidth := width - length + text = t.Section.Title.Render(text) + if remainingWidth > 0 { + text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth)) + } + return text +} diff --git a/internal/ui/model/files.go b/internal/ui/model/files.go new file mode 100644 index 0000000000000000000000000000000000000000..7526ee8473f591b298e1a89096c5c8db5225bb70 --- /dev/null +++ b/internal/ui/model/files.go @@ -0,0 +1,211 @@ +package model + +import ( + "context" + "fmt" + "path/filepath" + "slices" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/diff" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// SessionFile tracks the first and latest versions of a file in a session, +// along with the total additions and deletions. +type SessionFile struct { + FirstVersion history.File + LatestVersion history.File + Additions int + Deletions int +} + +// loadSessionFiles loads all files modified during a session and calculates +// their diff statistics. +func (m *UI) loadSessionFiles(sessionID string) tea.Cmd { + return func() tea.Msg { + files, err := m.com.App.History.ListBySession(context.Background(), sessionID) + if err != nil { + return err + } + 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 + } + + 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) + + sessionFiles = append(sessionFiles, SessionFile{ + FirstVersion: first, + LatestVersion: last, + Additions: additions, + Deletions: deletions, + }) + } + + 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 sessionFilesLoadedMsg{ + files: sessionFiles, + } + } +} + +// handleFileEvent processes file change events and updates the session file +// list with new or updated file information. +func (m *UI) handleFileEvent(file history.File) tea.Cmd { + if m.session == nil || file.SessionID != m.session.ID { + return nil + } + + 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 sessionFilesLoadedMsg{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) + } + } + + return sessionFilesLoadedMsg{files: newFiles} + } +} + +// filesInfo renders the modified files section for the sidebar, showing files +// with their addition/deletion counts. +func (m *UI) filesInfo(cwd string, width, maxItems int) string { + t := m.com.Styles + 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) + } + + return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) +} + +// 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 { + 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 { + break + } + + // Build stats string with colors + var statusParts []string + if f.Additions > 0 { + statusParts = append(statusParts, t.Files.Additions.Render(fmt.Sprintf("+%d", f.Additions))) + } + if f.Deletions > 0 { + statusParts = append(statusParts, t.Files.Deletions.Render(fmt.Sprintf("-%d", f.Deletions))) + } + extraContent := strings.Join(statusParts, " ") + + // Format file path + filePath := f.FirstVersion.Path + if rel, err := filepath.Rel(cwd, filePath); err == nil { + filePath = rel + } + filePath = fsext.DirTrim(filePath, 2) + filePath = ansi.Truncate(filePath, width-(lipgloss.Width(extraContent)-2), "…") + + line := t.Files.Path.Render(filePath) + if extraContent != "" { + line = fmt.Sprintf("%s %s", line, extraContent) + } + + renderedFiles = append(renderedFiles, line) + filesShown++ + } + + if len(filesWithChanges) > maxItems { + remaining := len(filesWithChanges) - maxItems + renderedFiles = append(renderedFiles, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) + } + + return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...) +} diff --git a/internal/ui/model/landing.go b/internal/ui/model/landing.go index dda843250db29a19626cd7dae5e67990bd85c1ad..a90ef76fdaf779e61477f5a05fd92a68d2e8a257 100644 --- a/internal/ui/model/landing.go +++ b/internal/ui/model/landing.go @@ -1,18 +1,14 @@ package model import ( - "cmp" - "fmt" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent" "github.com/charmbracelet/crush/internal/ui/common" uv "github.com/charmbracelet/ultraviolet" - "golang.org/x/text/cases" - "golang.org/x/text/language" ) +// selectedLargeModel returns the currently selected large language model from +// the agent coordinator, if one exists. func (m *UI) selectedLargeModel() *agent.Model { if m.com.App.AgentCoordinator != nil { model := m.com.App.AgentCoordinator.Model() @@ -21,6 +17,8 @@ func (m *UI) selectedLargeModel() *agent.Model { return nil } +// landingView renders the landing page view showing the current working +// directory, model information, and LSP/MCP status in a two-column layout. func (m *UI) landingView() string { t := m.com.Styles width := m.layout.main.Dx() @@ -30,34 +28,15 @@ func (m *UI) landingView() string { cwd, } - model := m.selectedLargeModel() - if model != nil && model.CatwalkCfg.CanReason { - reasoningInfo := "" - providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider) - if ok { - switch providerConfig.Type { - case catwalk.TypeAnthropic: - if model.ModelCfg.Think { - reasoningInfo = "Thinking On" - } else { - reasoningInfo = "Thinking Off" - } - default: - formatter := cases.Title(language.English, cases.NoLower) - reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort) - reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort)) - } - parts = append(parts, "", common.ModelInfo(t, model.CatwalkCfg.Name, reasoningInfo, nil, width)) - } - } + parts = append(parts, "", m.modelInfo(width)) infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...) _, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1)) mcpLspSectionWidth := min(30, (width-1)/2) - lspSection := m.lspInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy()) - mcpSection := m.mcpInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy()) + lspSection := m.lspInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false) + mcpSection := m.mcpInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false) content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection) diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go index 220133b793da374920c7bd0fc2ee9e0aecd3b67b..b1a3b8ebb223ce20687a0885f21a65a7ed1bf88a 100644 --- a/internal/ui/model/lsp.go +++ b/internal/ui/model/lsp.go @@ -12,13 +12,17 @@ import ( "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" ) +// LSPInfo wraps LSP client information with diagnostic counts by severity. type LSPInfo struct { app.LSPClientInfo Diagnostics map[protocol.DiagnosticSeverity]int } -func (m *UI) lspInfo(t *styles.Styles, width, height int) string { +// lspInfo renders the LSP status section showing active LSP clients and their +// diagnostic counts. +func (m *UI) lspInfo(width, maxItems int, isSection bool) string { var lsps []LSPInfo + t := m.com.Styles for _, state := range m.lspStates { client, ok := m.com.App.LSPClients.Get(state.Name) @@ -43,15 +47,18 @@ func (m *UI) lspInfo(t *styles.Styles, width, height int) string { lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs}) } title := t.Subtle.Render("LSPs") + if isSection { + title = common.Section(t, title, width) + } list := t.Subtle.Render("None") if len(lsps) > 0 { - height = max(0, height-2) // remove title and space - list = lspList(t, lsps, width, height) + list = lspList(t, lsps, width, maxItems) } return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) } +// lspDiagnostics formats diagnostic counts with appropriate icons and colors. func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string { errs := []string{} if diagnostics[protocol.SeverityError] > 0 { @@ -69,7 +76,9 @@ func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverit return strings.Join(errs, " ") } -func lspList(t *styles.Styles, lsps []LSPInfo, width, height int) string { +// lspList renders a list of LSP clients with their status and diagnostics, +// truncating to maxItems if needed. +func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string { var renderedLsps []string for _, l := range lsps { var icon string @@ -103,9 +112,9 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, height int) string { }, width)) } - if len(renderedLsps) > height { - visibleItems := renderedLsps[:height-1] - remaining := len(renderedLsps) - (height - 1) + if len(renderedLsps) > maxItems { + visibleItems := renderedLsps[:maxItems-1] + remaining := len(renderedLsps) - maxItems visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) return lipgloss.JoinVertical(lipgloss.Left, visibleItems...) } diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go index f4278a1e45afac2a7f8d12b495e1eb0be7cecfa5..2a58e15ac10175f29d6180aa7e98d954a644b34b 100644 --- a/internal/ui/model/mcp.go +++ b/internal/ui/model/mcp.go @@ -10,27 +10,29 @@ import ( "github.com/charmbracelet/crush/internal/ui/styles" ) -type MCPInfo struct { - mcp.ClientInfo -} - -func (m *UI) mcpInfo(t *styles.Styles, width, height int) string { - var mcps []MCPInfo +// mcpInfo renders the MCP status section showing active MCP clients and their +// tool/prompt counts. +func (m *UI) mcpInfo(width, maxItems int, isSection bool) string { + var mcps []mcp.ClientInfo + t := m.com.Styles for _, state := range m.mcpStates { - mcps = append(mcps, MCPInfo{ClientInfo: state}) + mcps = append(mcps, state) } title := t.Subtle.Render("MCPs") + if isSection { + title = common.Section(t, title, width) + } list := t.Subtle.Render("None") if len(mcps) > 0 { - height = max(0, height-2) // remove title and space - list = mcpList(t, mcps, width, height) + list = mcpList(t, mcps, width, maxItems) } return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) } +// mcpCounts formats tool and prompt counts for display. func mcpCounts(t *styles.Styles, counts mcp.Counts) string { parts := []string{} if counts.Tools > 0 { @@ -42,8 +44,11 @@ func mcpCounts(t *styles.Styles, counts mcp.Counts) string { return strings.Join(parts, " ") } -func mcpList(t *styles.Styles, mcps []MCPInfo, width, height int) string { +// mcpList renders a list of MCP clients with their status and counts, +// truncating to maxItems if needed. +func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string { var renderedMcps []string + for _, m := range mcps { var icon string title := m.Name @@ -78,9 +83,9 @@ func mcpList(t *styles.Styles, mcps []MCPInfo, width, height int) string { }, width)) } - if len(renderedMcps) > height { - visibleItems := renderedMcps[:height-1] - remaining := len(renderedMcps) - (height - 1) + if len(renderedMcps) > maxItems { + visibleItems := renderedMcps[:maxItems-1] + remaining := len(renderedMcps) - maxItems visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) return lipgloss.JoinVertical(lipgloss.Left, visibleItems...) } diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go index 2f50ae85d1086dd00674e87560b44a4ae2aad202..1b922282ae78bf0a89004abfff6098ec3240ff94 100644 --- a/internal/ui/model/onboarding.go +++ b/internal/ui/model/onboarding.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" ) +// 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() @@ -22,6 +23,7 @@ func (m *UI) markProjectInitialized() tea.Msg { return nil } +// updateInitializeView handles keyboard input for the project initialization prompt. func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) { switch { case key.Matches(msg, m.keyMap.Initialize.Enter): @@ -40,6 +42,7 @@ func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) { return cmds } +// initializeProject starts project initialization and transitions to the landing view. func (m *UI) initializeProject() tea.Cmd { // TODO: initialize the project // for now we just go to the landing page @@ -49,6 +52,7 @@ func (m *UI) initializeProject() tea.Cmd { return m.markProjectInitialized } +// skipInitializeProject skips project initialization and transitions to the landing view. func (m *UI) skipInitializeProject() tea.Cmd { // TODO: initialize the project m.state = uiLanding @@ -57,6 +61,7 @@ func (m *UI) skipInitializeProject() tea.Cmd { return m.markProjectInitialized } +// initializeView renders the project initialization prompt with Yes/No buttons. func (m *UI) initializeView() string { cfg := m.com.Config() s := m.com.Styles.Initialize diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 2f84565c98428126df612020ac0af35d88f46c78..1f63f020d228b8b975306fead7cb75d7167a717e 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -1,67 +1,150 @@ package model import ( - tea "charm.land/bubbletea/v2" + "cmp" + "fmt" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/logo" + uv "github.com/charmbracelet/ultraviolet" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) -// SidebarModel is the model for the sidebar UI component. -type SidebarModel struct { - com *common.Common +// modelInfo renders the current model information including reasoning +// settings and context usage/cost for the sidebar. +func (m *UI) modelInfo(width int) string { + model := m.selectedLargeModel() + reasoningInfo := "" + if model != nil && model.CatwalkCfg.CanReason { + providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider) + if ok { + switch providerConfig.Type { + case catwalk.TypeAnthropic: + if model.ModelCfg.Think { + reasoningInfo = "Thinking On" + } else { + reasoningInfo = "Thinking Off" + } + default: + formatter := cases.Title(language.English, cases.NoLower) + reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort) + reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort)) + } + } + } + var modelContext *common.ModelContextInfo + if m.session != nil { + modelContext = &common.ModelContextInfo{ + ContextUsed: m.session.CompletionTokens + m.session.PromptTokens, + Cost: m.session.Cost, + ModelContext: model.CatwalkCfg.ContextWindow, + } + } + return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, reasoningInfo, modelContext, width) +} + +// getDynamicHeightLimits will give us the num of items to show in each section based on the hight +// some items are more important than others. +func getDynamicHeightLimits(availableHeight int) (maxFiles, maxLSPs, maxMCPs int) { + const ( + minItemsPerSection = 2 + defaultMaxFilesShown = 10 + defaultMaxLSPsShown = 8 + defaultMaxMCPsShown = 8 + minAvailableHeightLimit = 10 + ) + + // If we have very little space, use minimum values + if availableHeight < minAvailableHeightLimit { + return minItemsPerSection, minItemsPerSection, minItemsPerSection + } - // width of the sidebar. - width int + // Distribute available height among the three sections + // Give priority to files, then LSPs, then MCPs + totalSections := 3 + heightPerSection := availableHeight / totalSections - // Cached rendered logo string. - logo string - // Cached cwd string. - cwd string + // 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)) - // TODO: lsp, files, session + // 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 - // Whether to render the sidebar in compact mode. - compact bool -} + if remainingHeight > 0 { + extraForLSPs := min(remainingHeight, defaultMaxLSPsShown-maxLSPs) + maxLSPs += extraForLSPs + remainingHeight -= extraForLSPs -// NewSidebarModel creates a new SidebarModel instance. -func NewSidebarModel(com *common.Common) *SidebarModel { - return &SidebarModel{ - com: com, - compact: true, - cwd: com.Config().WorkingDir(), + if remainingHeight > 0 { + maxMCPs += min(remainingHeight, defaultMaxMCPsShown-maxMCPs) + } + } } -} -// Init initializes the sidebar model. -func (m *SidebarModel) Init() tea.Cmd { - return nil + return maxFiles, maxLSPs, maxMCPs } -// Update updates the sidebar model based on incoming messages. -func (m *SidebarModel) Update(msg tea.Msg) (*SidebarModel, tea.Cmd) { - return m, nil -} +// sidebar renders the chat sidebar containing session title, working +// directory, model info, file list, LSP status, and MCP status. +func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) { + const logoHeightBreakpoint = 30 -// View renders the sidebar model as a string. -func (m *SidebarModel) View() string { - s := m.com.Styles.SidebarFull - if m.compact { - s = m.com.Styles.SidebarCompact - } + t := m.com.Styles + width := area.Dx() + height := area.Dy() + title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title) + cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width) + sidebarLogo := m.sidebarLogo + if height < logoHeightBreakpoint { + sidebarLogo = logo.SmallRender(width) + } blocks := []string{ - m.logo, + sidebarLogo, + title, + "", + cwd, + "", + m.modelInfo(width), + "", } - return s.Render(lipgloss.JoinVertical( - lipgloss.Top, + sidebarHeader := lipgloss.JoinVertical( + lipgloss.Left, blocks..., - )) -} + ) + + _, remainingHeightArea := uv.SplitVertical(m.layout.sidebar, uv.Fixed(lipgloss.Height(sidebarHeader))) + remainingHeight := remainingHeightArea.Dy() - 10 + maxFiles, maxLSPs, maxMCPs := getDynamicHeightLimits(remainingHeight) + + lspSection := m.lspInfo(width, maxLSPs, true) + mcpSection := m.mcpInfo(width, maxMCPs, true) + filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles) -// SetWidth sets the width of the sidebar and updates the logo accordingly. -func (m *SidebarModel) SetWidth(width int) { - m.logo = renderLogo(m.com.Styles, true, width) - m.width = width + uv.NewStyledString( + lipgloss.NewStyle(). + MaxWidth(width). + MaxHeight(height). + Render( + lipgloss.JoinVertical( + lipgloss.Left, + sidebarHeader, + filesSection, + "", + lspSection, + "", + mcpSection, + ), + ), + ).Draw(scr, area) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index cfa699b87f7c8d38d07410ff06b45e7958a3ac07..d81c02ee0d8dc7ca233476d5a789c2b25f9f0305 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1,6 +1,7 @@ package model import ( + "context" "image" "math/rand" "os" @@ -15,6 +16,7 @@ import ( "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/history" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" @@ -48,10 +50,19 @@ const ( uiChatCompact ) +type sessionLoadedMsg struct { + sess session.Session +} + +type sessionFilesLoadedMsg struct { + files []SessionFile +} + // UI represents the main user interface model. type UI struct { - com *common.Common - sess *session.Session + com *common.Common + session *session.Session + sessionFiles []SessionFile // The width and height of the terminal in cells. width int @@ -65,7 +76,6 @@ type UI struct { keyenh tea.KeyboardEnhancementsMsg chat *ChatModel - side *SidebarModel dialog *dialog.Overlay help help.Model @@ -98,6 +108,9 @@ type UI struct { // mcp mcpStates map[string]mcp.ClientInfo + + // sidebarLogo keeps a cached version of the sidebar sidebarLogo. + sidebarLogo string } // New creates a new instance of the [UI] model. @@ -114,7 +127,6 @@ func New(com *common.Common) *UI { com: com, dialog: dialog.NewOverlay(), keyMap: DefaultKeyMap(), - side: NewSidebarModel(com), help: help.New(), focus: uiFocusNone, state: uiConfigure, @@ -161,6 +173,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.sendProgressBar { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } + case sessionLoadedMsg: + m.state = uiChat + m.session = &msg.sess + case sessionFilesLoadedMsg: + m.sessionFiles = msg.files + case pubsub.Event[history.File]: + cmds = append(cmds, m.handleFileEvent(msg.Payload)) case pubsub.Event[app.LSPEvent]: m.lspStates = app.GetLSPStates() case pubsub.Event[mcp.Event]: @@ -203,6 +222,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +func (m *UI) loadSession(sessionID string) tea.Cmd { + return func() tea.Msg { + // TODO: handle error + session, _ := m.com.App.Sessions.Get(context.Background(), sessionID) + return sessionLoadedMsg{session} + } +} + func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { if m.dialog.HasDialogs() { return m.updateDialogs(msg) @@ -249,6 +276,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { layout := generateLayout(m, area.Dx(), area.Dy()) + // Update cached layout and component sizes if needed. + if m.layout != layout { + m.layout = layout + m.updateSize() + } + // Clear the screen first screen.Clear(scr) @@ -283,13 +316,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { case uiChat: header := uv.NewStyledString(m.header) header.Draw(scr, layout.header) - - side := uv.NewStyledString(m.side.View()) - side.Draw(scr, layout.sidebar) - + m.drawSidebar(scr, layout.sidebar) mainView := lipgloss.NewStyle().Width(layout.main.Dx()). Height(layout.main.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). Render(" Chat Messages ") main := uv.NewStyledString(mainView) main.Draw(scr, layout.main) @@ -385,16 +414,16 @@ func (m *UI) ShortHelp() []key.Binding { binds = append(binds, k.Quit) default: // TODO: other states - if m.sess == nil { - // no session selected - binds = append(binds, - k.Commands, - k.Models, - k.Editor.Newline, - k.Quit, - k.Help, - ) - } + // if m.session == nil { + // no session selected + binds = append(binds, + k.Commands, + k.Models, + k.Editor.Newline, + k.Quit, + k.Help, + ) + // } // else { // we have a session // } @@ -436,7 +465,7 @@ func (m *UI) FullHelp() [][]key.Binding { k.Quit, }) default: - if m.sess == nil { + if m.session == nil { // no session selected binds = append(binds, []key.Binding{ @@ -535,8 +564,7 @@ func (m *UI) updateSize() { m.textarea.SetHeight(m.layout.editor.Dy()) case uiChat: - // TODO: set the width and heigh of the chat component - m.side.SetWidth(m.layout.sidebar.Dx()) + m.renderSidebarLogo(m.layout.sidebar.Dx()) m.textarea.SetWidth(m.layout.editor.Dx()) m.textarea.SetHeight(m.layout.editor.Dy()) @@ -548,7 +576,8 @@ func (m *UI) updateSize() { } } -// generateLayout generates a [layout] for the given rectangle. +// generateLayout calculates the layout rectangles for all UI components based +// on the current UI state and terminal dimensions. func generateLayout(m *UI, w, h int) layout { // The screen area we're working with area := image.Rect(0, 0, w, h) @@ -558,7 +587,7 @@ func generateLayout(m *UI, w, h int) layout { // The editor height editorHeight := 5 // The sidebar width - sidebarWidth := 40 + sidebarWidth := 30 // The header height // TODO: handle compact headerHeight := 4 @@ -635,6 +664,8 @@ func generateLayout(m *UI, w, h int) layout { // help mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth)) + // Add padding left + sideRect.Min.X += 1 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) layout.sidebar = sideRect layout.main = mainRect @@ -690,6 +721,8 @@ type layout struct { help uv.Rectangle } +// setEditorPrompt configures the textarea prompt function based on whether +// yolo mode is enabled. func (m *UI) setEditorPrompt() { if m.com.App.Permissions.SkipRequests() { m.textarea.SetPromptFunc(4, m.yoloPromptFunc) @@ -698,6 +731,8 @@ func (m *UI) setEditorPrompt() { m.textarea.SetPromptFunc(4, m.normalPromptFunc) } +// normalPromptFunc returns the normal editor prompt style (" > " on first +// line, "::: " on subsequent lines). func (m *UI) normalPromptFunc(info textarea.PromptInfo) string { t := m.com.Styles if info.LineNumber == 0 { @@ -709,6 +744,8 @@ func (m *UI) normalPromptFunc(info textarea.PromptInfo) string { return t.EditorPromptNormalBlurred.Render() } +// yoloPromptFunc returns the yolo mode editor prompt style with warning icon +// and colored dots. func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string { t := m.com.Styles if info.LineNumber == 0 { @@ -740,16 +777,26 @@ var workingPlaceholders = [...]string{ "Thinking...", } +// randomizePlaceholders selects random placeholder text for the textarea's +// ready and working states. func (m *UI) randomizePlaceholders() { m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] } +// renderHeader renders and caches the header logo at the specified width. func (m *UI) renderHeader(compact bool, width int) { // TODO: handle the compact case differently 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) { + m.sidebarLogo = renderLogo(m.com.Styles, true, width) +} + +// 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{ FieldColor: t.LogoFieldColor, @@ -757,6 +804,6 @@ func renderLogo(t *styles.Styles, compact bool, width int) string { TitleColorB: t.LogoTitleColorB, CharmColor: t.LogoCharmColor, VersionColor: t.LogoVersionColor, - Width: max(0, width-2), + Width: width, }) } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 0b005f0b83690b53ead7951c0a0979cf83c7a073..049652225920098622e0988c8d073d5e95527d50 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -31,6 +31,8 @@ const ( BorderThin string = "│" BorderThick string = "▌" + + SectionSeparator string = "─" ) const ( @@ -122,9 +124,11 @@ type Styles struct { LogoCharmColor color.Color LogoVersionColor color.Color - // Sidebar - SidebarFull lipgloss.Style - SidebarCompact lipgloss.Style + // Section Title + Section struct { + Title lipgloss.Style + Line lipgloss.Style + } // Initialize Initialize struct { @@ -140,6 +144,13 @@ type Styles struct { HintDiagnostic lipgloss.Style InfoDiagnostic lipgloss.Style } + + // Files + Files struct { + Path lipgloss.Style + Additions lipgloss.Style + Deletions lipgloss.Style + } } func DefaultStyles() Styles { @@ -575,9 +586,9 @@ func DefaultStyles() Styles { s.LogoCharmColor = secondary s.LogoVersionColor = primary - // Sidebar - s.SidebarFull = lipgloss.NewStyle().Padding(1, 1) - s.SidebarCompact = s.SidebarFull.PaddingTop(0) + // Section + s.Section.Title = s.Subtle + s.Section.Line = s.Base.Foreground(charmtone.Charcoal) // Initialize s.Initialize.Header = s.Base @@ -595,6 +606,10 @@ func DefaultStyles() Styles { s.LSP.WarningDiagnostic = s.Base.Foreground(warning) s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted) s.LSP.InfoDiagnostic = s.Base.Foreground(info) + + s.Files.Path = s.Muted + s.Files.Additions = s.Base.Foreground(greenDark) + s.Files.Deletions = s.Base.Foreground(redDark) return s } From ece936a9bfa80b7ede2d8f09ee9a6c2cb55a6e0d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 26 Nov 2025 10:10:02 -0500 Subject: [PATCH 018/335] fix(ui): prevent panic when session is nil --- internal/ui/model/sidebar.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 1f63f020d228b8b975306fead7cb75d7167a717e..11d7b73baee60fbf68514ae34fb5aeaf459a16d9 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -96,6 +96,10 @@ func getDynamicHeightLimits(availableHeight int) (maxFiles, maxLSPs, maxMCPs int // sidebar renders the chat sidebar containing session title, working // directory, model info, file list, LSP status, and MCP status. func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) { + if m.session == nil { + return + } + const logoHeightBreakpoint = 30 t := m.com.Styles From b71baee343d715660693d430fa5ff420b6f9cfab Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 26 Nov 2025 11:55:55 -0500 Subject: [PATCH 019/335] feat(ui): add optimized list component with focus navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add high-performance scrollable list component with efficient rendering: **Core Architecture** - Master buffer caching with lazy viewport extraction - Dirty item tracking for partial updates - Smart buffer manipulation for append/prepend/delete operations **Performance Optimizations** - Viewport height changes no longer trigger master buffer rebuilds - In-place re-rendering when item heights unchanged - Efficient structural operations (append/prepend/delete via buffer slicing) - Focus navigation automatically skips non-focusable items **Item Types** - StringItem: Simple text with optional wrapping - MarkdownItem: Glamour-rendered markdown with optional focus styles - SpacerItem: Vertical spacing - FocusableItem: Wrapper to add focus behavior to any item **Focus Management** - Built-in Focusable interface support - Focus/blur state tracking with automatic item updates - Smart navigation that skips non-focusable items (Gap, Spacer) **Testing** - 32 tests covering core operations, rendering, focus, scrolling - Comprehensive test coverage for edge cases and regressions 💘 Generated with Crush Assisted-by: Claude Sonnet 4.5 via Crush --- internal/ui/list/example_test.go | 276 +++++++++++ internal/ui/list/item.go | 343 +++++++++++++ internal/ui/list/item_test.go | 602 +++++++++++++++++++++++ internal/ui/list/list.go | 795 +++++++++++++++++++++++++++++++ internal/ui/list/list_test.go | 586 +++++++++++++++++++++++ internal/ui/model/ui.go | 45 +- 6 files changed, 2631 insertions(+), 16 deletions(-) create mode 100644 internal/ui/list/example_test.go create mode 100644 internal/ui/list/item.go create mode 100644 internal/ui/list/item_test.go create mode 100644 internal/ui/list/list.go create mode 100644 internal/ui/list/list_test.go diff --git a/internal/ui/list/example_test.go b/internal/ui/list/example_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d88a616ddb7a58d547c55fb72e46f7fab31a9ec8 --- /dev/null +++ b/internal/ui/list/example_test.go @@ -0,0 +1,276 @@ +package list_test + +import ( + "fmt" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/list" + uv "github.com/charmbracelet/ultraviolet" +) + +// Example demonstrates basic list usage with string items. +func Example_basic() { + // Create some items + items := []list.Item{ + list.NewStringItem("1", "First item"), + list.NewStringItem("2", "Second item"), + list.NewStringItem("3", "Third item"), + } + + // Create a list with options + l := list.New(items...) + l.SetSize(80, 10) + l.SetSelectedIndex(0) + if true { + l.Focus() + } + + // Draw to a screen buffer + screen := uv.NewScreenBuffer(80, 10) + area := uv.Rect(0, 0, 80, 10) + l.Draw(&screen, area) + + // Render to string + output := screen.Render() + fmt.Println(output) +} + +// BorderedItem demonstrates a focusable item with borders. +type BorderedItem struct { + id string + content string + focused bool + width int +} + +func NewBorderedItem(id, content string) *BorderedItem { + return &BorderedItem{ + id: id, + content: content, + width: 80, + } +} + +func (b *BorderedItem) ID() string { + return b.id +} + +func (b *BorderedItem) Height(width int) int { + // Account for border (2 lines for top and bottom) + b.width = width // Update width for rendering + return lipgloss.Height(b.render()) +} + +func (b *BorderedItem) Draw(scr uv.Screen, area uv.Rectangle) { + rendered := b.render() + styled := uv.NewStyledString(rendered) + styled.Draw(scr, area) +} + +func (b *BorderedItem) render() string { + style := lipgloss.NewStyle(). + Width(b.width-4). + Padding(0, 1) + + if b.focused { + style = style. + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("205")) + } else { + style = style. + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")) + } + + return style.Render(b.content) +} + +func (b *BorderedItem) Focus() { + b.focused = true +} + +func (b *BorderedItem) Blur() { + b.focused = false +} + +func (b *BorderedItem) IsFocused() bool { + return b.focused +} + +// Example demonstrates focusable items with borders. +func Example_focusable() { + // Create focusable items + items := []list.Item{ + NewBorderedItem("1", "Focusable Item 1"), + NewBorderedItem("2", "Focusable Item 2"), + NewBorderedItem("3", "Focusable Item 3"), + } + + // Create list with first item selected and focused + l := list.New(items...) + l.SetSize(80, 20) + l.SetSelectedIndex(0) + if true { + l.Focus() + } + + // Draw to screen + screen := uv.NewScreenBuffer(80, 20) + area := uv.Rect(0, 0, 80, 20) + l.Draw(&screen, area) + + // The first item will have a colored border since it's focused + output := screen.Render() + fmt.Println(output) +} + +// Example demonstrates dynamic item updates. +func Example_dynamicUpdates() { + items := []list.Item{ + list.NewStringItem("1", "Item 1"), + list.NewStringItem("2", "Item 2"), + } + + l := list.New(items...) + l.SetSize(80, 10) + + // Draw initial state + screen := uv.NewScreenBuffer(80, 10) + area := uv.Rect(0, 0, 80, 10) + l.Draw(&screen, area) + + // Update an item + l.UpdateItem("2", list.NewStringItem("2", "Updated Item 2")) + + // Draw again - only changed item is re-rendered + l.Draw(&screen, area) + + // Append a new item + l.AppendItem(list.NewStringItem("3", "New Item 3")) + + // Draw again - master buffer grows efficiently + l.Draw(&screen, area) + + output := screen.Render() + fmt.Println(output) +} + +// Example demonstrates scrolling with a large list. +func Example_scrolling() { + // Create many items + items := make([]list.Item, 100) + for i := range items { + items[i] = list.NewStringItem( + fmt.Sprintf("%d", i), + fmt.Sprintf("Item %d", i), + ) + } + + // Create list with small viewport + l := list.New(items...) + l.SetSize(80, 10) + l.SetSelectedIndex(0) + + // Draw initial view (shows items 0-9) + screen := uv.NewScreenBuffer(80, 10) + area := uv.Rect(0, 0, 80, 10) + l.Draw(&screen, area) + + // Scroll down + l.ScrollBy(5) + l.Draw(&screen, area) // Now shows items 5-14 + + // Jump to specific item + l.ScrollToItem("50") + l.Draw(&screen, area) // Now shows item 50 and neighbors + + // Scroll to bottom + l.ScrollToBottom() + l.Draw(&screen, area) // Now shows last 10 items + + output := screen.Render() + fmt.Println(output) +} + +// VariableHeightItem demonstrates items with different heights. +type VariableHeightItem struct { + id string + lines []string + width int +} + +func NewVariableHeightItem(id string, lines []string) *VariableHeightItem { + return &VariableHeightItem{ + id: id, + lines: lines, + width: 80, + } +} + +func (v *VariableHeightItem) ID() string { + return v.id +} + +func (v *VariableHeightItem) Height(width int) int { + return len(v.lines) +} + +func (v *VariableHeightItem) Draw(scr uv.Screen, area uv.Rectangle) { + content := "" + for i, line := range v.lines { + if i > 0 { + content += "\n" + } + content += line + } + styled := uv.NewStyledString(content) + styled.Draw(scr, area) +} + +// Example demonstrates variable height items. +func Example_variableHeights() { + items := []list.Item{ + NewVariableHeightItem("1", []string{"Short item"}), + NewVariableHeightItem("2", []string{ + "This is a taller item", + "that spans multiple lines", + "to demonstrate variable heights", + }), + NewVariableHeightItem("3", []string{"Another short item"}), + NewVariableHeightItem("4", []string{ + "A medium height item", + "with two lines", + }), + } + + l := list.New(items...) + l.SetSize(80, 15) + + screen := uv.NewScreenBuffer(80, 15) + area := uv.Rect(0, 0, 80, 15) + l.Draw(&screen, area) + + output := screen.Render() + fmt.Println(output) +} + +// Example demonstrates markdown items in a list. +func Example_markdown() { + // Create markdown items + items := []list.Item{ + list.NewMarkdownItem("1", "# Welcome\n\nThis is a **markdown** item."), + list.NewMarkdownItem("2", "## Features\n\n- Supports **bold**\n- Supports *italic*\n- Supports `code`"), + list.NewMarkdownItem("3", "### Code Block\n\n```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```"), + } + + // Create list + l := list.New(items...) + l.SetSize(80, 20) + + screen := uv.NewScreenBuffer(80, 20) + area := uv.Rect(0, 0, 80, 20) + l.Draw(&screen, area) + + output := screen.Render() + fmt.Println(output) +} diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go new file mode 100644 index 0000000000000000000000000000000000000000..8e8a5cf27a022bb1af9daefd4f162dee0acd9a48 --- /dev/null +++ b/internal/ui/list/item.go @@ -0,0 +1,343 @@ +package list + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/glamour/v2" + "github.com/charmbracelet/glamour/v2/ansi" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/ultraviolet/screen" +) + +// Item represents a list item that can draw itself to a UV buffer. +// Items implement the uv.Drawable interface. +type Item interface { + uv.Drawable + + // ID returns unique identifier for this item. + ID() string + + // Height returns the item's height in lines for the given width. + // This allows items to calculate height based on text wrapping and available space. + Height(width int) int +} + +// Focusable is an optional interface for items that support focus. +// When implemented, items can change appearance when focused (borders, colors, etc). +type Focusable interface { + Focus() + Blur() + IsFocused() bool +} + +// BaseFocusable provides common focus state and styling for items. +// Embed this type to add focus behavior to any item. +type BaseFocusable struct { + focused bool + focusStyle *lipgloss.Style + blurStyle *lipgloss.Style +} + +// Focus implements Focusable interface. +func (b *BaseFocusable) Focus() { + b.focused = true +} + +// Blur implements Focusable interface. +func (b *BaseFocusable) Blur() { + b.focused = false +} + +// IsFocused implements Focusable interface. +func (b *BaseFocusable) IsFocused() bool { + return b.focused +} + +// HasFocusStyles returns true if both focus and blur styles are configured. +func (b *BaseFocusable) HasFocusStyles() bool { + return b.focusStyle != nil && b.blurStyle != nil +} + +// CurrentStyle returns the current style based on focus state. +// Returns nil if no styles are configured, or if the current state's style is nil. +func (b *BaseFocusable) CurrentStyle() *lipgloss.Style { + if b.focused { + return b.focusStyle + } + return b.blurStyle +} + +// SetFocusStyles sets the focus and blur styles. +func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) { + b.focusStyle = focusStyle + b.blurStyle = blurStyle +} + +// StringItem is a simple string-based item with optional text wrapping. +// It caches rendered content by width for efficient repeated rendering. +// StringItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles. +type StringItem struct { + BaseFocusable + id string + content string // Raw content string (may contain ANSI styles) + wrap bool // Whether to wrap text + + // Cache for rendered content at specific widths + // Key: width, Value: string + cache map[int]string +} + +// NewStringItem creates a new string item with the given ID and content. +func NewStringItem(id, content string) *StringItem { + return &StringItem{ + id: id, + content: content, + wrap: false, + cache: make(map[int]string), + } +} + +// NewWrappingStringItem creates a new string item that wraps text to fit width. +func NewWrappingStringItem(id, content string) *StringItem { + return &StringItem{ + id: id, + content: content, + wrap: true, + cache: make(map[int]string), + } +} + +// WithFocusStyles sets the focus and blur styles for the string item. +// If both styles are non-nil, the item will implement Focusable. +func (s *StringItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *StringItem { + s.SetFocusStyles(focusStyle, blurStyle) + return s +} + +// ID implements Item. +func (s *StringItem) ID() string { + return s.id +} + +// Height implements Item. +func (s *StringItem) Height(width int) int { + // Calculate content width if we have styles + contentWidth := width + if style := s.CurrentStyle(); style != nil { + hFrameSize := style.GetHorizontalFrameSize() + if hFrameSize > 0 { + contentWidth -= hFrameSize + } + } + + var lines int + if !s.wrap { + // No wrapping - height is just the number of newlines + 1 + lines = strings.Count(s.content, "\n") + 1 + } else { + // Use lipgloss.Wrap to wrap the content and count lines + // This preserves ANSI styles and is much faster than rendering to a buffer + wrapped := lipgloss.Wrap(s.content, contentWidth, "") + lines = strings.Count(wrapped, "\n") + 1 + } + + // Add vertical frame size if we have styles + if style := s.CurrentStyle(); style != nil { + lines += style.GetVerticalFrameSize() + } + + return lines +} + +// Draw implements Item and uv.Drawable. +func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) { + width := area.Dx() + + // Check cache first + content, ok := s.cache[width] + if !ok { + // Not cached - create and cache + content = s.content + if s.wrap { + // Wrap content using lipgloss + content = lipgloss.Wrap(s.content, width, "") + } + s.cache[width] = content + } + + // Apply focus/blur styling if configured + if style := s.CurrentStyle(); style != nil { + content = style.Width(width).Render(content) + } + + // Draw the styled string + styled := uv.NewStyledString(content) + styled.Draw(scr, area) +} + +// MarkdownItem renders markdown content using Glamour. +// It caches all rendered content by width for efficient repeated rendering. +// The wrap width is capped at 120 cells by default to ensure readable line lengths. +// MarkdownItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles. +type MarkdownItem struct { + BaseFocusable + id string + markdown string // Raw markdown content + styleConfig *ansi.StyleConfig // Optional style configuration + maxWidth int // Maximum wrap width (default 120) + + // Cache for rendered content at specific widths + // Key: width (capped to maxWidth), Value: rendered markdown string + cache map[int]string +} + +// DefaultMarkdownMaxWidth is the default maximum width for markdown rendering. +const DefaultMarkdownMaxWidth = 120 + +// NewMarkdownItem creates a new markdown item with the given ID and markdown content. +// If focusStyle and blurStyle are both non-nil, the item will implement Focusable. +func NewMarkdownItem(id, markdown string) *MarkdownItem { + m := &MarkdownItem{ + id: id, + markdown: markdown, + maxWidth: DefaultMarkdownMaxWidth, + cache: make(map[int]string), + } + + return m +} + +// WithStyleConfig sets a custom Glamour style configuration for the markdown item. +func (m *MarkdownItem) WithStyleConfig(styleConfig ansi.StyleConfig) *MarkdownItem { + m.styleConfig = &styleConfig + return m +} + +// WithMaxWidth sets the maximum wrap width for markdown rendering. +func (m *MarkdownItem) WithMaxWidth(maxWidth int) *MarkdownItem { + m.maxWidth = maxWidth + return m +} + +// WithFocusStyles sets the focus and blur styles for the markdown item. +// If both styles are non-nil, the item will implement Focusable. +func (m *MarkdownItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *MarkdownItem { + m.SetFocusStyles(focusStyle, blurStyle) + return m +} + +// ID implements Item. +func (m *MarkdownItem) ID() string { + return m.id +} + +// Height implements Item. +func (m *MarkdownItem) Height(width int) int { + // Render the markdown to get its height + rendered := m.renderMarkdown(width) + + // Apply focus/blur styling if configured to get accurate height + if style := m.CurrentStyle(); style != nil { + rendered = style.Render(rendered) + } + + return strings.Count(rendered, "\n") + 1 +} + +// Draw implements Item and uv.Drawable. +func (m *MarkdownItem) Draw(scr uv.Screen, area uv.Rectangle) { + width := area.Dx() + rendered := m.renderMarkdown(width) + + // Apply focus/blur styling if configured + if style := m.CurrentStyle(); style != nil { + rendered = style.Render(rendered) + } + + // Draw the rendered markdown + styled := uv.NewStyledString(rendered) + styled.Draw(scr, area) +} + +// renderMarkdown renders the markdown content at the given width, using cache if available. +// Width is always capped to maxWidth to ensure readable line lengths. +func (m *MarkdownItem) renderMarkdown(width int) string { + // Cap width to maxWidth + cappedWidth := min(width, m.maxWidth) + + // Check cache first (always cache all rendered markdown) + if cached, ok := m.cache[cappedWidth]; ok { + return cached + } + + // Not cached - render now + opts := []glamour.TermRendererOption{ + glamour.WithWordWrap(cappedWidth), + } + + // Add style config if provided + if m.styleConfig != nil { + opts = append(opts, glamour.WithStyles(*m.styleConfig)) + } + + renderer, err := glamour.NewTermRenderer(opts...) + if err != nil { + // Fallback to plain text on error + return m.markdown + } + + rendered, err := renderer.Render(m.markdown) + if err != nil { + // Fallback to plain text on error + return m.markdown + } + + // Trim trailing whitespace + rendered = strings.TrimRight(rendered, "\n\r ") + + // Always cache + m.cache[cappedWidth] = rendered + + return rendered +} + +// Gap is a 1-line spacer item used to add gaps between items. +var Gap = NewSpacerItem("spacer-gap", 1) + +// SpacerItem is an empty item that takes up vertical space. +// Useful for adding gaps between items in a list. +type SpacerItem struct { + id string + height int +} + +var _ Item = (*SpacerItem)(nil) + +// NewSpacerItem creates a new spacer item with the given ID and height in lines. +func NewSpacerItem(id string, height int) *SpacerItem { + return &SpacerItem{ + id: id, + height: height, + } +} + +// ID implements Item. +func (s *SpacerItem) ID() string { + return s.id +} + +// Height implements Item. +func (s *SpacerItem) Height(width int) int { + return s.height +} + +// Draw implements Item. +// Spacer items don't draw anything, they just take up space. +func (s *SpacerItem) Draw(scr uv.Screen, area uv.Rectangle) { + // Ensure the area is filled with spaces to clear any existing content + spacerArea := uv.Rect(area.Min.X, area.Min.Y, area.Dx(), area.Min.Y+min(1, s.height)) + if spacerArea.Overlaps(area) { + screen.ClearArea(scr, spacerArea) + } +} diff --git a/internal/ui/list/item_test.go b/internal/ui/list/item_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4ed2529441fcf2954436eacb0699224054ae4ee4 --- /dev/null +++ b/internal/ui/list/item_test.go @@ -0,0 +1,602 @@ +package list + +import ( + "strings" + "testing" + + "github.com/charmbracelet/glamour/v2/ansi" + uv "github.com/charmbracelet/ultraviolet" +) + +func TestRenderHelper(t *testing.T) { + items := []Item{ + NewStringItem("1", "Item 1"), + NewStringItem("2", "Item 2"), + NewStringItem("3", "Item 3"), + } + + l := New(items...) + l.SetSize(80, 10) + + // Render to string + output := l.Render() + + if len(output) == 0 { + t.Error("expected non-empty output from Render()") + } + + // Check that output contains the items + if !strings.Contains(output, "Item 1") { + t.Error("expected output to contain 'Item 1'") + } + if !strings.Contains(output, "Item 2") { + t.Error("expected output to contain 'Item 2'") + } + if !strings.Contains(output, "Item 3") { + t.Error("expected output to contain 'Item 3'") + } +} + +func TestRenderWithScrolling(t *testing.T) { + items := []Item{ + NewStringItem("1", "Item 1"), + NewStringItem("2", "Item 2"), + NewStringItem("3", "Item 3"), + NewStringItem("4", "Item 4"), + NewStringItem("5", "Item 5"), + } + + l := New(items...) + l.SetSize(80, 2) // Small viewport + + // Initial render should show first 2 items + output := l.Render() + if !strings.Contains(output, "Item 1") { + t.Error("expected output to contain 'Item 1'") + } + if !strings.Contains(output, "Item 2") { + t.Error("expected output to contain 'Item 2'") + } + if strings.Contains(output, "Item 3") { + t.Error("expected output to NOT contain 'Item 3' in initial view") + } + + // Scroll down and render + l.ScrollBy(2) + output = l.Render() + + // Now should show items 3 and 4 + if strings.Contains(output, "Item 1") { + t.Error("expected output to NOT contain 'Item 1' after scrolling") + } + if !strings.Contains(output, "Item 3") { + t.Error("expected output to contain 'Item 3' after scrolling") + } + if !strings.Contains(output, "Item 4") { + t.Error("expected output to contain 'Item 4' after scrolling") + } +} + +func TestRenderEmptyList(t *testing.T) { + l := New() + l.SetSize(80, 10) + + output := l.Render() + if output != "" { + t.Errorf("expected empty output for empty list, got: %q", output) + } +} + +func TestRenderVsDrawConsistency(t *testing.T) { + items := []Item{ + NewStringItem("1", "Item 1"), + NewStringItem("2", "Item 2"), + } + + l := New(items...) + l.SetSize(80, 10) + + // Render using Render() method + renderOutput := l.Render() + + // Render using Draw() method + screen := uv.NewScreenBuffer(80, 10) + area := uv.Rect(0, 0, 80, 10) + l.Draw(&screen, area) + drawOutput := screen.Render() + + // Trim any trailing whitespace for comparison + renderOutput = strings.TrimRight(renderOutput, "\n") + drawOutput = strings.TrimRight(drawOutput, "\n") + + // Both methods should produce the same output + if renderOutput != drawOutput { + t.Errorf("Render() and Draw() produced different outputs:\nRender():\n%q\n\nDraw():\n%q", + renderOutput, drawOutput) + } +} + +func BenchmarkRender(b *testing.B) { + items := make([]Item, 100) + for i := range items { + items[i] = NewStringItem(string(rune(i)), "Item content here") + } + + l := New(items...) + l.SetSize(80, 24) + l.Render() // Prime the buffer + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = l.Render() + } +} + +func BenchmarkRenderWithScrolling(b *testing.B) { + items := make([]Item, 1000) + for i := range items { + items[i] = NewStringItem(string(rune(i)), "Item content here") + } + + l := New(items...) + l.SetSize(80, 24) + l.Render() // Prime the buffer + + b.ResetTimer() + for i := 0; i < b.N; i++ { + l.ScrollBy(1) + _ = l.Render() + } +} + +func TestStringItemCache(t *testing.T) { + item := NewStringItem("1", "Test content") + + // First draw at width 80 should populate cache + screen1 := uv.NewScreenBuffer(80, 5) + area1 := uv.Rect(0, 0, 80, 5) + item.Draw(&screen1, area1) + + if len(item.cache) != 1 { + t.Errorf("expected cache to have 1 entry after first draw, got %d", len(item.cache)) + } + if _, ok := item.cache[80]; !ok { + t.Error("expected cache to have entry for width 80") + } + + // Second draw at same width should reuse cache + screen2 := uv.NewScreenBuffer(80, 5) + area2 := uv.Rect(0, 0, 80, 5) + item.Draw(&screen2, area2) + + if len(item.cache) != 1 { + t.Errorf("expected cache to still have 1 entry after second draw, got %d", len(item.cache)) + } + + // Draw at different width should add to cache + screen3 := uv.NewScreenBuffer(40, 5) + area3 := uv.Rect(0, 0, 40, 5) + item.Draw(&screen3, area3) + + if len(item.cache) != 2 { + t.Errorf("expected cache to have 2 entries after draw at different width, got %d", len(item.cache)) + } + if _, ok := item.cache[40]; !ok { + t.Error("expected cache to have entry for width 40") + } +} + +func TestWrappingItemHeight(t *testing.T) { + // Short text that fits in one line + item1 := NewWrappingStringItem("1", "Short") + if h := item1.Height(80); h != 1 { + t.Errorf("expected height 1 for short text, got %d", h) + } + + // Long text that wraps + longText := "This is a very long line that will definitely wrap when constrained to a narrow width" + item2 := NewWrappingStringItem("2", longText) + + // At width 80, should be fewer lines than width 20 + height80 := item2.Height(80) + height20 := item2.Height(20) + + if height20 <= height80 { + t.Errorf("expected more lines at narrow width (20: %d lines) than wide width (80: %d lines)", + height20, height80) + } + + // Non-wrapping version should always be 1 line + item3 := NewStringItem("3", longText) + if h := item3.Height(20); h != 1 { + t.Errorf("expected height 1 for non-wrapping item, got %d", h) + } +} + +func TestMarkdownItemBasic(t *testing.T) { + markdown := "# Hello\n\nThis is a **test**." + item := NewMarkdownItem("1", markdown) + + if item.ID() != "1" { + t.Errorf("expected ID '1', got '%s'", item.ID()) + } + + // Test that height is calculated + height := item.Height(80) + if height < 1 { + t.Errorf("expected height >= 1, got %d", height) + } + + // Test drawing + screen := uv.NewScreenBuffer(80, 10) + area := uv.Rect(0, 0, 80, 10) + item.Draw(&screen, area) + + // Should not panic and should render something + rendered := screen.Render() + if len(rendered) == 0 { + t.Error("expected non-empty rendered output") + } +} + +func TestMarkdownItemCache(t *testing.T) { + markdown := "# Test\n\nSome content." + item := NewMarkdownItem("1", markdown) + + // First render at width 80 should populate cache + height1 := item.Height(80) + if len(item.cache) != 1 { + t.Errorf("expected cache to have 1 entry after first render, got %d", len(item.cache)) + } + + // Second render at same width should reuse cache + height2 := item.Height(80) + if height1 != height2 { + t.Errorf("expected consistent height, got %d then %d", height1, height2) + } + if len(item.cache) != 1 { + t.Errorf("expected cache to still have 1 entry, got %d", len(item.cache)) + } + + // Render at different width should add to cache + _ = item.Height(40) + if len(item.cache) != 2 { + t.Errorf("expected cache to have 2 entries after different width, got %d", len(item.cache)) + } +} + +func TestMarkdownItemMaxCacheWidth(t *testing.T) { + markdown := "# Test\n\nSome content." + item := NewMarkdownItem("1", markdown).WithMaxWidth(50) + + // Render at width 40 (below limit) - should cache at width 40 + _ = item.Height(40) + if len(item.cache) != 1 { + t.Errorf("expected cache to have 1 entry for width 40, got %d", len(item.cache)) + } + + // Render at width 80 (above limit) - should cap to 50 and cache + _ = item.Height(80) + // Cache should have width 50 entry (capped from 80) + if len(item.cache) != 2 { + t.Errorf("expected cache to have 2 entries (40 and 50), got %d", len(item.cache)) + } + if _, ok := item.cache[50]; !ok { + t.Error("expected cache to have entry for width 50 (capped from 80)") + } + + // Render at width 100 (also above limit) - should reuse cached width 50 + _ = item.Height(100) + if len(item.cache) != 2 { + t.Errorf("expected cache to still have 2 entries (reusing 50), got %d", len(item.cache)) + } +} + +func TestMarkdownItemWithStyleConfig(t *testing.T) { + markdown := "# Styled\n\nContent with **bold** text." + + // Create a custom style config + styleConfig := ansi.StyleConfig{ + Document: ansi.StyleBlock{ + Margin: uintPtr(0), + }, + } + + item := NewMarkdownItem("1", markdown).WithStyleConfig(styleConfig) + + // Render should use the custom style + height := item.Height(80) + if height < 1 { + t.Errorf("expected height >= 1, got %d", height) + } + + // Draw should work without panic + screen := uv.NewScreenBuffer(80, 10) + area := uv.Rect(0, 0, 80, 10) + item.Draw(&screen, area) + + rendered := screen.Render() + if len(rendered) == 0 { + t.Error("expected non-empty rendered output with custom style") + } +} + +func TestMarkdownItemInList(t *testing.T) { + items := []Item{ + NewMarkdownItem("1", "# First\n\nMarkdown item."), + NewMarkdownItem("2", "# Second\n\nAnother item."), + NewStringItem("3", "Regular string item"), + } + + l := New(items...) + l.SetSize(80, 20) + + // Should render without error + output := l.Render() + if len(output) == 0 { + t.Error("expected non-empty output from list with markdown items") + } + + // Should contain content from markdown items + if !strings.Contains(output, "First") { + t.Error("expected output to contain 'First'") + } + if !strings.Contains(output, "Second") { + t.Error("expected output to contain 'Second'") + } + if !strings.Contains(output, "Regular string item") { + t.Error("expected output to contain 'Regular string item'") + } +} + +func TestMarkdownItemHeightWithWidth(t *testing.T) { + // Test that widths are capped to maxWidth + markdown := "This is a paragraph with some text." + + item := NewMarkdownItem("1", markdown).WithMaxWidth(50) + + // At width 30 (below limit), should cache and render at width 30 + height30 := item.Height(30) + if height30 < 1 { + t.Errorf("expected height >= 1, got %d", height30) + } + + // At width 100 (above maxWidth), should cap to 50 and cache + height100 := item.Height(100) + if height100 < 1 { + t.Errorf("expected height >= 1, got %d", height100) + } + + // Both should be cached (width 30 and capped width 50) + if len(item.cache) != 2 { + t.Errorf("expected cache to have 2 entries (30 and 50), got %d", len(item.cache)) + } + if _, ok := item.cache[30]; !ok { + t.Error("expected cache to have entry for width 30") + } + if _, ok := item.cache[50]; !ok { + t.Error("expected cache to have entry for width 50 (capped from 100)") + } +} + +func BenchmarkMarkdownItemRender(b *testing.B) { + markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3" + item := NewMarkdownItem("1", markdown) + + // Prime the cache + screen := uv.NewScreenBuffer(80, 10) + area := uv.Rect(0, 0, 80, 10) + item.Draw(&screen, area) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + screen := uv.NewScreenBuffer(80, 10) + area := uv.Rect(0, 0, 80, 10) + item.Draw(&screen, area) + } +} + +func BenchmarkMarkdownItemUncached(b *testing.B) { + markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + item := NewMarkdownItem("1", markdown) + screen := uv.NewScreenBuffer(80, 10) + area := uv.Rect(0, 0, 80, 10) + item.Draw(&screen, area) + } +} + +func TestSpacerItem(t *testing.T) { + spacer := NewSpacerItem("spacer1", 3) + + // Check ID + if spacer.ID() != "spacer1" { + t.Errorf("expected ID 'spacer1', got %q", spacer.ID()) + } + + // Check height + if h := spacer.Height(80); h != 3 { + t.Errorf("expected height 3, got %d", h) + } + + // Height should be constant regardless of width + if h := spacer.Height(20); h != 3 { + t.Errorf("expected height 3 for width 20, got %d", h) + } + + // Draw should not produce any visible content + screen := uv.NewScreenBuffer(20, 3) + area := uv.Rect(0, 0, 20, 3) + spacer.Draw(&screen, area) + + output := screen.Render() + // Should be empty (just spaces) + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + t.Errorf("expected empty spacer output, got: %q", line) + } + } +} + +func TestSpacerItemInList(t *testing.T) { + // Create a list with items separated by spacers + items := []Item{ + NewStringItem("1", "Item 1"), + NewSpacerItem("spacer1", 1), + NewStringItem("2", "Item 2"), + NewSpacerItem("spacer2", 2), + NewStringItem("3", "Item 3"), + } + + l := New(items...) + l.SetSize(20, 10) + + output := l.Render() + + // Should contain all three items + if !strings.Contains(output, "Item 1") { + t.Error("expected output to contain 'Item 1'") + } + if !strings.Contains(output, "Item 2") { + t.Error("expected output to contain 'Item 2'") + } + if !strings.Contains(output, "Item 3") { + t.Error("expected output to contain 'Item 3'") + } + + // Total height should be: 1 (item1) + 1 (spacer1) + 1 (item2) + 2 (spacer2) + 1 (item3) = 6 + expectedHeight := 6 + if l.TotalHeight() != expectedHeight { + t.Errorf("expected total height %d, got %d", expectedHeight, l.TotalHeight()) + } +} + +func TestSpacerItemNavigation(t *testing.T) { + // Spacers should not be selectable (they're not focusable) + items := []Item{ + NewStringItem("1", "Item 1"), + NewSpacerItem("spacer1", 1), + NewStringItem("2", "Item 2"), + } + + l := New(items...) + l.SetSize(20, 10) + + // Select first item + l.SetSelectedIndex(0) + if l.SelectedIndex() != 0 { + t.Errorf("expected selected index 0, got %d", l.SelectedIndex()) + } + + // Can select the spacer (it's a valid item, just not focusable) + l.SetSelectedIndex(1) + if l.SelectedIndex() != 1 { + t.Errorf("expected selected index 1, got %d", l.SelectedIndex()) + } + + // Can select item after spacer + l.SetSelectedIndex(2) + if l.SelectedIndex() != 2 { + t.Errorf("expected selected index 2, got %d", l.SelectedIndex()) + } +} + +// Helper function to create a pointer to uint +func uintPtr(v uint) *uint { + return &v +} + +func TestListDoesNotEatLastLine(t *testing.T) { + // Create items that exactly fill the viewport + items := []Item{ + NewStringItem("1", "Line 1"), + NewStringItem("2", "Line 2"), + NewStringItem("3", "Line 3"), + NewStringItem("4", "Line 4"), + NewStringItem("5", "Line 5"), + } + + // Create list with height exactly matching content (5 lines, no gaps) + l := New(items...) + l.SetSize(20, 5) + + // Render the list + output := l.Render() + + // Count actual lines in output + lines := strings.Split(strings.TrimRight(output, "\r\n"), "\r\n") + actualLineCount := 0 + for _, line := range lines { + if strings.TrimSpace(line) != "" { + actualLineCount++ + } + } + + // All 5 items should be visible + if !strings.Contains(output, "Line 1") { + t.Error("expected output to contain 'Line 1'") + } + if !strings.Contains(output, "Line 2") { + t.Error("expected output to contain 'Line 2'") + } + if !strings.Contains(output, "Line 3") { + t.Error("expected output to contain 'Line 3'") + } + if !strings.Contains(output, "Line 4") { + t.Error("expected output to contain 'Line 4'") + } + if !strings.Contains(output, "Line 5") { + t.Error("expected output to contain 'Line 5'") + } + + if actualLineCount != 5 { + t.Errorf("expected 5 lines with content, got %d", actualLineCount) + } +} + +func TestListWithScrollDoesNotEatLastLine(t *testing.T) { + // Create more items than viewport height + items := []Item{ + NewStringItem("1", "Item 1"), + NewStringItem("2", "Item 2"), + NewStringItem("3", "Item 3"), + NewStringItem("4", "Item 4"), + NewStringItem("5", "Item 5"), + NewStringItem("6", "Item 6"), + NewStringItem("7", "Item 7"), + } + + // Viewport shows 3 items at a time + l := New(items...) + l.SetSize(20, 3) + + // Need to render first to build the buffer and calculate total height + _ = l.Render() + + // Now scroll to bottom + l.ScrollToBottom() + + output := l.Render() + + t.Logf("Output:\n%s", output) + t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight()) + + // Should show last 3 items: 5, 6, 7 + if !strings.Contains(output, "Item 5") { + t.Error("expected output to contain 'Item 5'") + } + if !strings.Contains(output, "Item 6") { + t.Error("expected output to contain 'Item 6'") + } + if !strings.Contains(output, "Item 7") { + t.Error("expected output to contain 'Item 7'") + } + + // Should not show earlier items + if strings.Contains(output, "Item 1") { + t.Error("expected output to NOT contain 'Item 1' when scrolled to bottom") + } +} diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go new file mode 100644 index 0000000000000000000000000000000000000000..0b87aca1790776666a97cfb7cb76fe83933375e2 --- /dev/null +++ b/internal/ui/list/list.go @@ -0,0 +1,795 @@ +package list + +import ( + "strings" + + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/ultraviolet/screen" +) + +// List is a scrollable list component that implements uv.Drawable. +// It efficiently manages a large number of items by caching rendered content +// in a master buffer and extracting only the visible viewport when drawn. +type List struct { + // Configuration + width, height int + + // Data + items []Item + indexMap map[string]int // ID -> index for fast lookup + + // Focus & Selection + focused bool + selectedIdx int // Currently selected item index (-1 if none) + + // Master buffer containing ALL rendered items + masterBuffer *uv.ScreenBuffer + totalHeight int + + // Item positioning in master buffer + itemPositions map[string]itemPosition + + // Viewport state + offset int // Scroll offset in lines from top + + // Dirty tracking + dirty bool + dirtyItems map[string]bool +} + +type itemPosition struct { + startLine int + height int +} + +// New creates a new list with the given items. +func New(items ...Item) *List { + l := &List{ + items: items, + indexMap: make(map[string]int, len(items)), + itemPositions: make(map[string]itemPosition, len(items)), + dirtyItems: make(map[string]bool), + selectedIdx: -1, + } + + // Build index map + for i, item := range items { + l.indexMap[item.ID()] = i + } + + l.dirty = true + return l +} + +// ensureBuilt ensures the master buffer is built. +// This is called by methods that need itemPositions or totalHeight. +func (l *List) ensureBuilt() { + if l.width <= 0 || l.height <= 0 { + return + } + + if l.dirty { + l.rebuildMasterBuffer() + } else if len(l.dirtyItems) > 0 { + l.updateDirtyItems() + } +} + +// Draw implements uv.Drawable. +// Draws the visible viewport of the list to the given screen buffer. +func (l *List) Draw(scr uv.Screen, area uv.Rectangle) { + if area.Dx() <= 0 || area.Dy() <= 0 { + return + } + + // Update internal dimensions if area size changed + widthChanged := l.width != area.Dx() + heightChanged := l.height != area.Dy() + + l.width = area.Dx() + l.height = area.Dy() + + // Only width changes require rebuilding master buffer + // Height changes only affect viewport clipping, not item rendering + if widthChanged { + l.dirty = true + } + + // Height changes require clamping offset to new bounds + if heightChanged { + l.clampOffset() + } + + if len(l.items) == 0 { + screen.ClearArea(scr, area) + return + } + + // Ensure buffer is built + l.ensureBuilt() + + // Draw visible portion to the target screen + l.drawViewport(scr, area) +} + +// Render renders the visible viewport to a string. +// This is a convenience method that creates a temporary screen buffer, +// draws to it, and returns the rendered string. +func (l *List) Render() string { + if l.width <= 0 || l.height <= 0 { + return "" + } + + if len(l.items) == 0 { + return "" + } + + // Ensure buffer is built + l.ensureBuilt() + + // Extract visible lines directly from master buffer + return l.renderViewport() +} + +// renderViewport renders the visible portion of the master buffer to a string. +func (l *List) renderViewport() string { + if l.masterBuffer == nil { + return "" + } + + buf := l.masterBuffer.Buffer + + // Calculate visible region in master buffer + srcStartY := l.offset + srcEndY := l.offset + l.height + + // Clamp to actual buffer bounds + if srcStartY >= len(buf.Lines) { + // Beyond end of content, return empty lines + emptyLine := strings.Repeat(" ", l.width) + lines := make([]string, l.height) + for i := range lines { + lines[i] = emptyLine + } + return strings.Join(lines, "\r\n") + } + if srcEndY > len(buf.Lines) { + srcEndY = len(buf.Lines) + } + + // Build result with proper line handling + lines := make([]string, l.height) + lineIdx := 0 + + // Render visible lines from buffer + for y := srcStartY; y < srcEndY && lineIdx < l.height; y++ { + lines[lineIdx] = buf.Lines[y].Render() + lineIdx++ + } + + // Pad remaining lines with spaces to maintain viewport height + emptyLine := strings.Repeat(" ", l.width) + for ; lineIdx < l.height; lineIdx++ { + lines[lineIdx] = emptyLine + } + + return strings.Join(lines, "\r\n") +} + +// drawViewport draws the visible portion from master buffer to target screen. +func (l *List) drawViewport(scr uv.Screen, area uv.Rectangle) { + if l.masterBuffer == nil { + screen.ClearArea(scr, area) + return + } + + buf := l.masterBuffer.Buffer + + // Calculate visible region in master buffer + srcStartY := l.offset + srcEndY := l.offset + area.Dy() + + // Clamp to actual buffer bounds + if srcStartY >= len(buf.Lines) { + screen.ClearArea(scr, area) + return + } + if srcEndY > len(buf.Lines) { + srcEndY = len(buf.Lines) + } + + // Copy visible lines to target screen + destY := area.Min.Y + for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ { + line := buf.Lines[srcY] + destX := area.Min.X + + for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ { + cell := line.At(x) + scr.SetCell(destX, destY, cell) + destX++ + } + destY++ + } + + // Clear any remaining area if content is shorter than viewport + if destY < area.Max.Y { + clearArea := uv.Rect(area.Min.X, destY, area.Dx(), area.Max.Y-destY) + screen.ClearArea(scr, clearArea) + } +} + +// rebuildMasterBuffer composes all items into the master buffer. +func (l *List) rebuildMasterBuffer() { + if len(l.items) == 0 { + l.totalHeight = 0 + l.dirty = false + return + } + + // Calculate total height + l.totalHeight = l.calculateTotalHeight() + + // Create or resize master buffer + if l.masterBuffer == nil || l.masterBuffer.Width() != l.width || l.masterBuffer.Height() != l.totalHeight { + buf := uv.NewScreenBuffer(l.width, l.totalHeight) + l.masterBuffer = &buf + } + + // Clear buffer + screen.Clear(l.masterBuffer) + + // Draw each item + currentY := 0 + for _, item := range l.items { + itemHeight := item.Height(l.width) + + // Draw item to master buffer + area := uv.Rect(0, currentY, l.width, itemHeight) + item.Draw(l.masterBuffer, area) + + // Store position + l.itemPositions[item.ID()] = itemPosition{ + startLine: currentY, + height: itemHeight, + } + + // Advance position + currentY += itemHeight + } + + l.dirty = false + l.dirtyItems = make(map[string]bool) +} + +// updateDirtyItems efficiently updates only changed items using slice operations. +func (l *List) updateDirtyItems() { + if len(l.dirtyItems) == 0 { + return + } + + // Check if all dirty items have unchanged heights + allSameHeight := true + for id := range l.dirtyItems { + idx, ok := l.indexMap[id] + if !ok { + continue + } + + item := l.items[idx] + pos, ok := l.itemPositions[id] + if !ok { + l.dirty = true + l.dirtyItems = make(map[string]bool) + l.rebuildMasterBuffer() + return + } + + newHeight := item.Height(l.width) + if newHeight != pos.height { + allSameHeight = false + break + } + } + + // Optimization: If all dirty items have unchanged heights, re-render in place + if allSameHeight { + buf := l.masterBuffer.Buffer + for id := range l.dirtyItems { + idx := l.indexMap[id] + item := l.items[idx] + pos := l.itemPositions[id] + + // Clear the item's area + for y := pos.startLine; y < pos.startLine+pos.height && y < len(buf.Lines); y++ { + buf.Lines[y] = uv.NewLine(l.width) + } + + // Re-render item + area := uv.Rect(0, pos.startLine, l.width, pos.height) + item.Draw(l.masterBuffer, area) + } + + l.dirtyItems = make(map[string]bool) + return + } + + // Height changed - full rebuild + l.dirty = true + l.dirtyItems = make(map[string]bool) + l.rebuildMasterBuffer() +} + +// updatePositionsBelow updates the startLine for all items below the given index. +func (l *List) updatePositionsBelow(fromIdx int, delta int) { + for i := fromIdx + 1; i < len(l.items); i++ { + item := l.items[i] + pos := l.itemPositions[item.ID()] + pos.startLine += delta + l.itemPositions[item.ID()] = pos + } +} + +// calculateTotalHeight calculates the total height of all items plus gaps. +func (l *List) calculateTotalHeight() int { + if len(l.items) == 0 { + return 0 + } + + total := 0 + for _, item := range l.items { + total += item.Height(l.width) + } + return total +} + +// SetSize updates the viewport size. +func (l *List) SetSize(width, height int) { + widthChanged := l.width != width + heightChanged := l.height != height + + l.width = width + l.height = height + + // Width changes require full rebuild (items may reflow) + if widthChanged { + l.dirty = true + } + + // Height changes require clamping offset to new bounds + if heightChanged { + l.clampOffset() + } +} + +// GetSize returns the current viewport size. +func (l *List) GetSize() (int, int) { + return l.width, l.height +} + +// Len returns the number of items in the list. +func (l *List) Len() int { + return len(l.items) +} + +// SetItems replaces all items in the list. +func (l *List) SetItems(items []Item) { + l.items = items + l.indexMap = make(map[string]int, len(items)) + l.itemPositions = make(map[string]itemPosition, len(items)) + + for i, item := range items { + l.indexMap[item.ID()] = i + } + + l.dirty = true +} + +// Items returns all items in the list. +func (l *List) Items() []Item { + return l.items +} + +// AppendItem adds an item to the end of the list. +func (l *List) AppendItem(item Item) { + l.items = append(l.items, item) + l.indexMap[item.ID()] = len(l.items) - 1 + + // If buffer not built yet, mark dirty for full rebuild + if l.masterBuffer == nil || l.width <= 0 { + l.dirty = true + return + } + + // Process any pending dirty items before modifying buffer structure + if len(l.dirtyItems) > 0 { + l.updateDirtyItems() + } + + // Efficient append: insert lines at end of buffer + itemHeight := item.Height(l.width) + startLine := l.totalHeight + + // Expand buffer + newLines := make([]uv.Line, itemHeight) + for i := range newLines { + newLines[i] = uv.NewLine(l.width) + } + l.masterBuffer.Buffer.Lines = append(l.masterBuffer.Buffer.Lines, newLines...) + + // Draw new item + area := uv.Rect(0, startLine, l.width, itemHeight) + item.Draw(l.masterBuffer, area) + + // Update tracking + l.itemPositions[item.ID()] = itemPosition{ + startLine: startLine, + height: itemHeight, + } + l.totalHeight += itemHeight +} + +// PrependItem adds an item to the beginning of the list. +func (l *List) PrependItem(item Item) { + l.items = append([]Item{item}, l.items...) + + // Rebuild index map (all indices shifted) + l.indexMap = make(map[string]int, len(l.items)) + for i, itm := range l.items { + l.indexMap[itm.ID()] = i + } + + if l.selectedIdx >= 0 { + l.selectedIdx++ + } + + // If buffer not built yet, mark dirty for full rebuild + if l.masterBuffer == nil || l.width <= 0 { + l.dirty = true + return + } + + // Process any pending dirty items before modifying buffer structure + if len(l.dirtyItems) > 0 { + l.updateDirtyItems() + } + + // Efficient prepend: insert lines at start of buffer + itemHeight := item.Height(l.width) + + // Create new lines + newLines := make([]uv.Line, itemHeight) + for i := range newLines { + newLines[i] = uv.NewLine(l.width) + } + + // Insert at beginning + buf := l.masterBuffer.Buffer + buf.Lines = append(newLines, buf.Lines...) + + // Draw new item + area := uv.Rect(0, 0, l.width, itemHeight) + item.Draw(l.masterBuffer, area) + + // Update all positions (shift everything down) + for i := 1; i < len(l.items); i++ { + itm := l.items[i] + if pos, ok := l.itemPositions[itm.ID()]; ok { + pos.startLine += itemHeight + l.itemPositions[itm.ID()] = pos + } + } + + // Add position for new item + l.itemPositions[item.ID()] = itemPosition{ + startLine: 0, + height: itemHeight, + } + l.totalHeight += itemHeight +} + +// UpdateItem replaces an item with the same ID. +func (l *List) UpdateItem(id string, item Item) { + idx, ok := l.indexMap[id] + if !ok { + return + } + + l.items[idx] = item + l.dirtyItems[id] = true +} + +// DeleteItem removes an item by ID. +func (l *List) DeleteItem(id string) { + idx, ok := l.indexMap[id] + if !ok { + return + } + + // Get position before deleting + pos, hasPos := l.itemPositions[id] + + // Process any pending dirty items before modifying buffer structure + if len(l.dirtyItems) > 0 { + l.updateDirtyItems() + } + + l.items = append(l.items[:idx], l.items[idx+1:]...) + delete(l.indexMap, id) + delete(l.itemPositions, id) + + // Rebuild index map for items after deleted one + for i := idx; i < len(l.items); i++ { + l.indexMap[l.items[i].ID()] = i + } + + // Adjust selection + if l.selectedIdx == idx { + if idx > 0 { + l.selectedIdx = idx - 1 + } else if len(l.items) > 0 { + l.selectedIdx = 0 + } else { + l.selectedIdx = -1 + } + } else if l.selectedIdx > idx { + l.selectedIdx-- + } + + // If buffer not built yet, mark dirty for full rebuild + if l.masterBuffer == nil || !hasPos { + l.dirty = true + return + } + + // Efficient delete: remove lines from buffer + deleteStart := pos.startLine + deleteEnd := pos.startLine + pos.height + buf := l.masterBuffer.Buffer + + if deleteEnd <= len(buf.Lines) { + buf.Lines = append(buf.Lines[:deleteStart], buf.Lines[deleteEnd:]...) + l.totalHeight -= pos.height + l.updatePositionsBelow(idx-1, -pos.height) + } else { + // Position data corrupt, rebuild + l.dirty = true + } +} + +// Focus focuses the list and the selected item (if focusable). +func (l *List) Focus() { + l.focused = true + l.focusSelectedItem() +} + +// Blur blurs the list and the selected item (if focusable). +func (l *List) Blur() { + l.focused = false + l.blurSelectedItem() +} + +// IsFocused returns whether the list is focused. +func (l *List) IsFocused() bool { + return l.focused +} + +// SetSelected sets the selected item by ID. +func (l *List) SetSelected(id string) { + idx, ok := l.indexMap[id] + if !ok { + return + } + + if l.selectedIdx == idx { + return + } + + prevIdx := l.selectedIdx + l.selectedIdx = idx + + // Update focus states if list is focused + if l.focused { + if prevIdx >= 0 && prevIdx < len(l.items) { + if f, ok := l.items[prevIdx].(Focusable); ok { + f.Blur() + l.dirtyItems[l.items[prevIdx].ID()] = true + } + } + + if f, ok := l.items[idx].(Focusable); ok { + f.Focus() + l.dirtyItems[l.items[idx].ID()] = true + } + } +} + +// SetSelectedIndex sets the selected item by index. +func (l *List) SetSelectedIndex(idx int) { + if idx < 0 || idx >= len(l.items) { + return + } + l.SetSelected(l.items[idx].ID()) +} + +// SelectNext selects the next item in the list (wraps to beginning). +// When the list is focused, skips non-focusable items. +func (l *List) SelectNext() { + if len(l.items) == 0 { + return + } + + startIdx := l.selectedIdx + for i := 0; i < len(l.items); i++ { + nextIdx := (startIdx + 1 + i) % len(l.items) + + // If list is focused and item is not focusable, skip it + if l.focused { + if _, ok := l.items[nextIdx].(Focusable); !ok { + continue + } + } + + // Select and scroll to this item + l.SetSelected(l.items[nextIdx].ID()) + l.ScrollToSelected() + return + } +} + +// SelectPrev selects the previous item in the list (wraps to end). +// When the list is focused, skips non-focusable items. +func (l *List) SelectPrev() { + if len(l.items) == 0 { + return + } + + startIdx := l.selectedIdx + for i := 0; i < len(l.items); i++ { + prevIdx := (startIdx - 1 - i + len(l.items)) % len(l.items) + + // If list is focused and item is not focusable, skip it + if l.focused { + if _, ok := l.items[prevIdx].(Focusable); !ok { + continue + } + } + + // Select and scroll to this item + l.SetSelected(l.items[prevIdx].ID()) + l.ScrollToSelected() + return + } +} + +// SelectedItem returns the currently selected item, or nil if none. +func (l *List) SelectedItem() Item { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return nil + } + return l.items[l.selectedIdx] +} + +// SelectedIndex returns the index of the currently selected item, or -1 if none. +func (l *List) SelectedIndex() int { + return l.selectedIdx +} + +// AtBottom returns whether the viewport is scrolled to the bottom. +func (l *List) AtBottom() bool { + l.ensureBuilt() + return l.offset >= l.totalHeight-l.height +} + +// AtTop returns whether the viewport is scrolled to the top. +func (l *List) AtTop() bool { + return l.offset <= 0 +} + +// ScrollBy scrolls the viewport by the given number of lines. +// Positive values scroll down, negative scroll up. +func (l *List) ScrollBy(deltaLines int) { + l.offset += deltaLines + l.clampOffset() +} + +// ScrollToTop scrolls to the top of the list. +func (l *List) ScrollToTop() { + l.offset = 0 +} + +// ScrollToBottom scrolls to the bottom of the list. +func (l *List) ScrollToBottom() { + l.ensureBuilt() + if l.totalHeight > l.height { + l.offset = l.totalHeight - l.height + } else { + l.offset = 0 + } +} + +// ScrollToItem scrolls to make the item with the given ID visible. +func (l *List) ScrollToItem(id string) { + l.ensureBuilt() + pos, ok := l.itemPositions[id] + if !ok { + return + } + + itemStart := pos.startLine + itemEnd := pos.startLine + pos.height + viewStart := l.offset + viewEnd := l.offset + l.height + + // Check if item is already fully visible + if itemStart >= viewStart && itemEnd <= viewEnd { + return + } + + // Scroll to show item + if itemStart < viewStart { + l.offset = itemStart + } else if itemEnd > viewEnd { + l.offset = itemEnd - l.height + } + + l.clampOffset() +} + +// ScrollToSelected scrolls to make the selected item visible. +func (l *List) ScrollToSelected() { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return + } + l.ScrollToItem(l.items[l.selectedIdx].ID()) +} + +// Offset returns the current scroll offset. +func (l *List) Offset() int { + return l.offset +} + +// TotalHeight returns the total height of all items including gaps. +func (l *List) TotalHeight() int { + return l.totalHeight +} + +// clampOffset ensures offset is within valid bounds. +func (l *List) clampOffset() { + maxOffset := l.totalHeight - l.height + if maxOffset < 0 { + maxOffset = 0 + } + + if l.offset < 0 { + l.offset = 0 + } else if l.offset > maxOffset { + l.offset = maxOffset + } +} + +// focusSelectedItem focuses the currently selected item if it's focusable. +func (l *List) focusSelectedItem() { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return + } + + item := l.items[l.selectedIdx] + if f, ok := item.(Focusable); ok { + f.Focus() + l.dirtyItems[item.ID()] = true + } +} + +// blurSelectedItem blurs the currently selected item if it's focusable. +func (l *List) blurSelectedItem() { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return + } + + item := l.items[l.selectedIdx] + if f, ok := item.(Focusable); ok { + f.Blur() + l.dirtyItems[item.ID()] = true + } +} diff --git a/internal/ui/list/list_test.go b/internal/ui/list/list_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5ec228e698fae6b52c6c44e981348d964fbd46ca --- /dev/null +++ b/internal/ui/list/list_test.go @@ -0,0 +1,586 @@ +package list + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + uv "github.com/charmbracelet/ultraviolet" + "github.com/stretchr/testify/require" +) + +func TestNewList(t *testing.T) { + items := []Item{ + NewStringItem("1", "Item 1"), + NewStringItem("2", "Item 2"), + NewStringItem("3", "Item 3"), + } + + l := New(items...) + l.SetSize(80, 24) + + if len(l.items) != 3 { + t.Errorf("expected 3 items, got %d", len(l.items)) + } + + if l.width != 80 || l.height != 24 { + t.Errorf("expected size 80x24, got %dx%d", l.width, l.height) + } +} + +func TestListDraw(t *testing.T) { + items := []Item{ + NewStringItem("1", "Item 1"), + NewStringItem("2", "Item 2"), + NewStringItem("3", "Item 3"), + } + + l := New(items...) + l.SetSize(80, 10) + + // Create a screen buffer to draw into + screen := uv.NewScreenBuffer(80, 10) + area := uv.Rect(0, 0, 80, 10) + + // Draw the list + l.Draw(&screen, area) + + // Verify the buffer has content + output := screen.Render() + if len(output) == 0 { + t.Error("expected non-empty output") + } +} + +func TestListAppendItem(t *testing.T) { + items := []Item{ + NewStringItem("1", "Item 1"), + } + + l := New(items...) + l.AppendItem(NewStringItem("2", "Item 2")) + + if len(l.items) != 2 { + t.Errorf("expected 2 items after append, got %d", len(l.items)) + } + + if l.items[1].ID() != "2" { + t.Errorf("expected item ID '2', got '%s'", l.items[1].ID()) + } +} + +func TestListDeleteItem(t *testing.T) { + items := []Item{ + NewStringItem("1", "Item 1"), + NewStringItem("2", "Item 2"), + NewStringItem("3", "Item 3"), + } + + l := New(items...) + l.DeleteItem("2") + + if len(l.items) != 2 { + t.Errorf("expected 2 items after delete, got %d", len(l.items)) + } + + if l.items[1].ID() != "3" { + t.Errorf("expected item ID '3', got '%s'", l.items[1].ID()) + } +} + +func TestListUpdateItem(t *testing.T) { + items := []Item{ + NewStringItem("1", "Item 1"), + NewStringItem("2", "Item 2"), + } + + l := New(items...) + l.SetSize(80, 10) + + // Update item + newItem := NewStringItem("2", "Updated Item 2") + l.UpdateItem("2", newItem) + + if l.items[1].(*StringItem).content != "Updated Item 2" { + t.Errorf("expected updated content, got '%s'", l.items[1].(*StringItem).content) + } +} + +func TestListSelection(t *testing.T) { + items := []Item{ + NewStringItem("1", "Item 1"), + NewStringItem("2", "Item 2"), + NewStringItem("3", "Item 3"), + } + + l := New(items...) + l.SetSelectedIndex(0) + + if l.SelectedIndex() != 0 { + t.Errorf("expected selected index 0, got %d", l.SelectedIndex()) + } + + l.SelectNext() + if l.SelectedIndex() != 1 { + t.Errorf("expected selected index 1 after SelectNext, got %d", l.SelectedIndex()) + } + + l.SelectPrev() + if l.SelectedIndex() != 0 { + t.Errorf("expected selected index 0 after SelectPrev, got %d", l.SelectedIndex()) + } +} + +func TestListScrolling(t *testing.T) { + items := []Item{ + NewStringItem("1", "Item 1"), + NewStringItem("2", "Item 2"), + NewStringItem("3", "Item 3"), + NewStringItem("4", "Item 4"), + NewStringItem("5", "Item 5"), + } + + l := New(items...) + l.SetSize(80, 2) // Small viewport + + // Draw to initialize the master buffer + screen := uv.NewScreenBuffer(80, 2) + area := uv.Rect(0, 0, 80, 2) + l.Draw(&screen, area) + + if l.Offset() != 0 { + t.Errorf("expected initial offset 0, got %d", l.Offset()) + } + + l.ScrollBy(2) + if l.Offset() != 2 { + t.Errorf("expected offset 2 after ScrollBy(2), got %d", l.Offset()) + } + + l.ScrollToTop() + if l.Offset() != 0 { + t.Errorf("expected offset 0 after ScrollToTop, got %d", l.Offset()) + } +} + +// FocusableTestItem is a test item that implements Focusable. +type FocusableTestItem struct { + id string + content string + focused bool +} + +func (f *FocusableTestItem) ID() string { + return f.id +} + +func (f *FocusableTestItem) Height(width int) int { + return 1 +} + +func (f *FocusableTestItem) Draw(scr uv.Screen, area uv.Rectangle) { + prefix := "[ ]" + if f.focused { + prefix = "[X]" + } + content := prefix + " " + f.content + styled := uv.NewStyledString(content) + styled.Draw(scr, area) +} + +func (f *FocusableTestItem) Focus() { + f.focused = true +} + +func (f *FocusableTestItem) Blur() { + f.focused = false +} + +func (f *FocusableTestItem) IsFocused() bool { + return f.focused +} + +func TestListFocus(t *testing.T) { + items := []Item{ + &FocusableTestItem{id: "1", content: "Item 1"}, + &FocusableTestItem{id: "2", content: "Item 2"}, + } + + l := New(items...) + l.SetSize(80, 10) + l.SetSelectedIndex(0) + + // Focus the list + l.Focus() + + if !l.IsFocused() { + t.Error("expected list to be focused") + } + + // Check if selected item is focused + selectedItem := l.SelectedItem().(*FocusableTestItem) + if !selectedItem.IsFocused() { + t.Error("expected selected item to be focused") + } + + // Select next and check focus changes + l.SelectNext() + if selectedItem.IsFocused() { + t.Error("expected previous item to be blurred") + } + + newSelectedItem := l.SelectedItem().(*FocusableTestItem) + if !newSelectedItem.IsFocused() { + t.Error("expected new selected item to be focused") + } + + // Blur the list + l.Blur() + if l.IsFocused() { + t.Error("expected list to be blurred") + } +} + +// TestFocusNavigationAfterAppendingToViewportHeight reproduces the bug: +// Append items until viewport is full, select last, then navigate backwards. +func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) { + t.Parallel() + + focusStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("86")) + + blurStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")) + + // Start with one item + items := []Item{ + NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle), + } + + l := New(items...) + l.SetSize(20, 15) // 15 lines viewport height + l.SetSelectedIndex(0) + l.Focus() + + // Initial draw to build buffer + screen := uv.NewScreenBuffer(20, 15) + l.Draw(&screen, uv.Rect(0, 0, 20, 15)) + + // Append items until we exceed viewport height + // Each focusable item with border is 5 lines tall + for i := 2; i <= 4; i++ { + item := NewStringItem(string(rune('0'+i)), "Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle) + l.AppendItem(item) + } + + // Select the last item + l.SetSelectedIndex(3) + + // Draw + screen = uv.NewScreenBuffer(20, 15) + l.Draw(&screen, uv.Rect(0, 0, 20, 15)) + output := screen.Render() + + t.Logf("After selecting last item:\n%s", output) + require.Contains(t, output, "38;5;86", "expected focus color on last item") + + // Now navigate backwards + l.SelectPrev() + + screen = uv.NewScreenBuffer(20, 15) + l.Draw(&screen, uv.Rect(0, 0, 20, 15)) + output = screen.Render() + + t.Logf("After SelectPrev:\n%s", output) + require.Contains(t, output, "38;5;86", "expected focus color after SelectPrev") + + // Navigate backwards again + l.SelectPrev() + + screen = uv.NewScreenBuffer(20, 15) + l.Draw(&screen, uv.Rect(0, 0, 20, 15)) + output = screen.Render() + + t.Logf("After second SelectPrev:\n%s", output) + require.Contains(t, output, "38;5;86", "expected focus color after second SelectPrev") +} + +func TestFocusableItemUpdate(t *testing.T) { + // Create styles with borders + focusStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("86")) + + blurStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")) + + // Create a focusable item + item := NewStringItem("1", "Test Item").WithFocusStyles(&focusStyle, &blurStyle) + + // Initially not focused - render with blur style + screen1 := uv.NewScreenBuffer(20, 5) + area := uv.Rect(0, 0, 20, 5) + item.Draw(&screen1, area) + output1 := screen1.Render() + + // Focus the item + item.Focus() + + // Render again - should show focus style + screen2 := uv.NewScreenBuffer(20, 5) + item.Draw(&screen2, area) + output2 := screen2.Render() + + // Outputs should be different (different border colors) + if output1 == output2 { + t.Error("expected different output after focusing, but got same output") + } + + // Verify focus state + if !item.IsFocused() { + t.Error("expected item to be focused") + } + + // Blur the item + item.Blur() + + // Render again - should show blur style again + screen3 := uv.NewScreenBuffer(20, 5) + item.Draw(&screen3, area) + output3 := screen3.Render() + + // Output should match original blur output + if output1 != output3 { + t.Error("expected same output after blurring as initial state") + } + + // Verify blur state + if item.IsFocused() { + t.Error("expected item to be blurred") + } +} + +func TestFocusableItemHeightWithBorder(t *testing.T) { + // Create a style with a border (adds 2 to vertical height) + borderStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()) + + // Item without styles has height 1 + plainItem := NewStringItem("1", "Test") + plainHeight := plainItem.Height(20) + if plainHeight != 1 { + t.Errorf("expected plain height 1, got %d", plainHeight) + } + + // Item with border should add border height (2 lines) + item := NewStringItem("2", "Test").WithFocusStyles(&borderStyle, &borderStyle) + itemHeight := item.Height(20) + expectedHeight := 1 + 2 // content + border + if itemHeight != expectedHeight { + t.Errorf("expected height %d (content 1 + border 2), got %d", + expectedHeight, itemHeight) + } +} + +func TestFocusableItemInList(t *testing.T) { + focusStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("86")) + + blurStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")) + + // Create list with focusable items + items := []Item{ + NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle), + NewStringItem("2", "Item 2").WithFocusStyles(&focusStyle, &blurStyle), + NewStringItem("3", "Item 3").WithFocusStyles(&focusStyle, &blurStyle), + } + + l := New(items...) + l.SetSize(80, 20) + l.SetSelectedIndex(0) + + // Focus the list + l.Focus() + + // First item should be focused + firstItem := items[0].(*StringItem) + if !firstItem.IsFocused() { + t.Error("expected first item to be focused after focusing list") + } + + // Render to ensure changes are visible + output1 := l.Render() + if !strings.Contains(output1, "Item 1") { + t.Error("expected output to contain first item") + } + + // Select second item + l.SetSelectedIndex(1) + + // First item should be blurred, second focused + if firstItem.IsFocused() { + t.Error("expected first item to be blurred after changing selection") + } + + secondItem := items[1].(*StringItem) + if !secondItem.IsFocused() { + t.Error("expected second item to be focused after selection") + } + + // Render again - should show updated focus + output2 := l.Render() + if !strings.Contains(output2, "Item 2") { + t.Error("expected output to contain second item") + } + + // Outputs should be different + if output1 == output2 { + t.Error("expected different output after selection change") + } +} + +func TestFocusableItemWithNilStyles(t *testing.T) { + // Test with nil styles - should render inner item directly + item := NewStringItem("1", "Plain Item").WithFocusStyles(nil, nil) + + // Height should be based on content (no border since styles are nil) + itemHeight := item.Height(20) + if itemHeight != 1 { + t.Errorf("expected height 1 (no border), got %d", itemHeight) + } + + // Draw should work without styles + screen := uv.NewScreenBuffer(20, 5) + area := uv.Rect(0, 0, 20, 5) + item.Draw(&screen, area) + output := screen.Render() + + // Should contain the inner content + if !strings.Contains(output, "Plain Item") { + t.Error("expected output to contain inner item content") + } + + // Focus/blur should still work but not change appearance + item.Focus() + screen2 := uv.NewScreenBuffer(20, 5) + item.Draw(&screen2, area) + output2 := screen2.Render() + + // Output should be identical since no styles + if output != output2 { + t.Error("expected same output with nil styles whether focused or not") + } + + if !item.IsFocused() { + t.Error("expected item to be focused") + } +} + +func TestFocusableItemWithOnlyFocusStyle(t *testing.T) { + // Test with only focus style (blur is nil) + focusStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("86")) + + item := NewStringItem("1", "Test").WithFocusStyles(&focusStyle, nil) + + // When not focused, should use nil blur style (no border) + screen1 := uv.NewScreenBuffer(20, 5) + area := uv.Rect(0, 0, 20, 5) + item.Draw(&screen1, area) + output1 := screen1.Render() + + // Focus the item + item.Focus() + screen2 := uv.NewScreenBuffer(20, 5) + item.Draw(&screen2, area) + output2 := screen2.Render() + + // Outputs should be different (focused has border, blurred doesn't) + if output1 == output2 { + t.Error("expected different output when only focus style is set") + } +} + +func TestFocusableItemLastLineNotEaten(t *testing.T) { + // Create focusable items with borders + focusStyle := lipgloss.NewStyle(). + Padding(1). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("86")) + + blurStyle := lipgloss.NewStyle(). + BorderForeground(lipgloss.Color("240")) + + items := []Item{ + NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle), + Gap, + NewStringItem("2", "Item 2").WithFocusStyles(&focusStyle, &blurStyle), + Gap, + NewStringItem("3", "Item 3").WithFocusStyles(&focusStyle, &blurStyle), + Gap, + NewStringItem("4", "Item 4").WithFocusStyles(&focusStyle, &blurStyle), + Gap, + NewStringItem("5", "Item 5").WithFocusStyles(&focusStyle, &blurStyle), + } + + // Items with padding(1) and border are 5 lines each + // Viewport of 10 lines fits exactly 2 items + l := New() + l.SetSize(20, 10) + + for _, item := range items { + l.AppendItem(item) + } + + // Focus the list + l.Focus() + + // Select last item + l.SetSelectedIndex(len(items) - 1) + + // Scroll to bottom + l.ScrollToBottom() + + output := l.Render() + + t.Logf("Output:\n%s", output) + t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight()) + + // Select previous - will skip gaps and go to Item 4 + l.SelectPrev() + + output = l.Render() + + t.Logf("Output:\n%s", output) + t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight()) + + // Should show items 3 (unfocused), 4 (focused), and part of 5 (unfocused) + if !strings.Contains(output, "Item 3") { + t.Error("expected output to contain 'Item 3'") + } + if !strings.Contains(output, "Item 4") { + t.Error("expected output to contain 'Item 4'") + } + if !strings.Contains(output, "Item 5") { + t.Error("expected output to contain 'Item 5'") + } + + // Count bottom borders - should have 1 (focused item 4) + bottomBorderCount := 0 + for _, line := range strings.Split(output, "\r\n") { + if strings.Contains(line, "╰") || strings.Contains(line, "└") { + bottomBorderCount++ + } + } + + if bottomBorderCount != 1 { + t.Errorf("expected 1 bottom border (focused item 4), got %d", bottomBorderCount) + } +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index d81c02ee0d8dc7ca233476d5a789c2b25f9f0305..7a3b2847fb3b8c659d69fe4645cd1228ee881c88 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2,6 +2,7 @@ package model import ( "context" + "fmt" "image" "math/rand" "os" @@ -21,6 +22,7 @@ import ( "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" + "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/logo" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/version" @@ -75,7 +77,7 @@ type UI struct { keyMap KeyMap keyenh tea.KeyboardEnhancementsMsg - chat *ChatModel + chat *list.List dialog *dialog.Overlay help help.Model @@ -123,6 +125,8 @@ func New(com *common.Common) *UI { ta.SetVirtualCursor(false) ta.Focus() + l := list.New() + ui := &UI{ com: com, dialog: dialog.NewOverlay(), @@ -131,6 +135,7 @@ func New(com *common.Common) *UI { focus: uiFocusNone, state: uiConfigure, textarea: ta, + chat: l, } // set onboarding state defaults @@ -194,6 +199,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.updateLayoutAndSize() + m.chat.ScrollToBottom() case tea.KeyboardEnhancementsMsg: m.keyenh = msg if msg.SupportsKeyDisambiguation() { @@ -236,6 +242,23 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { } switch { + case msg.String() == "ctrl+shift+t": + m.chat.SelectPrev() + case msg.String() == "ctrl+t": + m.focus = uiFocusMain + m.state = uiChat + if m.chat.Len() > 0 { + m.chat.AppendItem(list.Gap) + } + m.chat.AppendItem( + list.NewStringItem( + fmt.Sprintf("%d", m.chat.Len()), + fmt.Sprintf("Welcome to Crush Chat! %d", rand.Intn(1000)), + ).WithFocusStyles(&m.com.Styles.BorderFocus, &m.com.Styles.BorderBlur), + ) + m.chat.SetSelectedIndex(m.chat.Len() - 1) + m.chat.Focus() + m.chat.ScrollToBottom() case key.Matches(msg, m.keyMap.Tab): switch m.state { case uiChat: @@ -317,11 +340,8 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { header := uv.NewStyledString(m.header) header.Draw(scr, layout.header) m.drawSidebar(scr, layout.sidebar) - mainView := lipgloss.NewStyle().Width(layout.main.Dx()). - Height(layout.main.Dy()). - Render(" Chat Messages ") - main := uv.NewStyledString(mainView) - main.Draw(scr, layout.main) + + m.chat.Draw(scr, layout.main) editor := uv.NewStyledString(m.textarea.View()) editor.Draw(scr, layout.editor) @@ -517,7 +537,6 @@ func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) { case uiChat, uiLanding, uiChatCompact: switch m.focus { case uiFocusMain: - cmds = append(cmds, m.updateChat(msg)...) case uiFocusEditor: switch { case key.Matches(msg, m.keyMap.Editor.Newline): @@ -533,15 +552,6 @@ func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) { return cmds } -// updateChat updates the chat model with the given message and appends any -// resulting commands to the cmds slice. -func (m *UI) updateChat(msg tea.KeyPressMsg) (cmds []tea.Cmd) { - updatedChat, cmd := m.chat.Update(msg) - m.chat = updatedChat - cmds = append(cmds, cmd) - return cmds -} - // updateLayoutAndSize updates the layout and sizes of UI components. func (m *UI) updateLayoutAndSize() { m.layout = generateLayout(m, m.width, m.height) @@ -567,6 +577,7 @@ func (m *UI) updateSize() { m.renderSidebarLogo(m.layout.sidebar.Dx()) m.textarea.SetWidth(m.layout.editor.Dx()) m.textarea.SetHeight(m.layout.editor.Dy()) + m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy()) case uiChatCompact: // TODO: set the width and heigh of the chat component @@ -667,6 +678,8 @@ func generateLayout(m *UI, w, h int) layout { // Add padding left sideRect.Min.X += 1 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + // Add bottom margin to main + mainRect.Max.Y -= 1 layout.sidebar = sideRect layout.main = mainRect layout.editor = editorRect From f48a3edc33ca62dd9d6ff5f6a226f12f659ce57a Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 2 Dec 2025 11:33:13 -0500 Subject: [PATCH 020/335] feat: implement text highlighting in list items (#1536) --- internal/ui/list/item.go | 278 ++++++++++++++++++++++++- internal/ui/list/list.go | 376 +++++++++++++++++++++++++++++++++- internal/ui/list/list_test.go | 4 +- 3 files changed, 639 insertions(+), 19 deletions(-) diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go index 8e8a5cf27a022bb1af9daefd4f162dee0acd9a48..1b85cff42a18e1f12f801605b301778773510590 100644 --- a/internal/ui/list/item.go +++ b/internal/ui/list/item.go @@ -1,6 +1,7 @@ package list import ( + "image" "strings" "charm.land/lipgloss/v2" @@ -10,6 +11,50 @@ import ( "github.com/charmbracelet/ultraviolet/screen" ) +// toUVStyle converts a lipgloss.Style to a uv.Style, stripping multiline attributes. +func toUVStyle(lgStyle lipgloss.Style) uv.Style { + var uvStyle uv.Style + + // Colors are already color.Color + uvStyle.Fg = lgStyle.GetForeground() + uvStyle.Bg = lgStyle.GetBackground() + + // Build attributes using bitwise OR + var attrs uint8 + + if lgStyle.GetBold() { + attrs |= uv.AttrBold + } + + if lgStyle.GetItalic() { + attrs |= uv.AttrItalic + } + + if lgStyle.GetUnderline() { + uvStyle.Underline = uv.UnderlineSingle + } + + if lgStyle.GetStrikethrough() { + attrs |= uv.AttrStrikethrough + } + + if lgStyle.GetFaint() { + attrs |= uv.AttrFaint + } + + if lgStyle.GetBlink() { + attrs |= uv.AttrBlink + } + + if lgStyle.GetReverse() { + attrs |= uv.AttrReverse + } + + uvStyle.Attrs = attrs + + return uvStyle +} + // Item represents a list item that can draw itself to a UV buffer. // Items implement the uv.Drawable interface. type Item interface { @@ -31,6 +76,17 @@ type Focusable interface { IsFocused() bool } +// Highlightable is an optional interface for items that support highlighting. +// When implemented, items can highlight specific regions (e.g. for search matches). +type Highlightable interface { + // SetHighlight sets the highlight region (startLine, startCol) to (endLine, endCol). + // Use -1 for all values to clear highlighting. + SetHighlight(startLine, startCol, endLine, endCol int) + + // GetHighlight returns the current highlight region. + GetHighlight() (startLine, startCol, endLine, endCol int) +} + // BaseFocusable provides common focus state and styling for items. // Embed this type to add focus behavior to any item. type BaseFocusable struct { @@ -74,11 +130,148 @@ func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) { b.blurStyle = blurStyle } +// BaseHighlightable provides common highlight state for items. +// Embed this type to add highlight behavior to any item. +type BaseHighlightable struct { + highlightStartLine int + highlightStartCol int + highlightEndLine int + highlightEndCol int + highlightStyle CellStyler +} + +// SetHighlight implements Highlightable interface. +func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) { + b.highlightStartLine = startLine + b.highlightStartCol = startCol + b.highlightEndLine = endLine + b.highlightEndCol = endCol +} + +// GetHighlight implements Highlightable interface. +func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) { + return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol +} + +// HasHighlight returns true if a highlight region is set. +func (b *BaseHighlightable) HasHighlight() bool { + return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 || + b.highlightEndLine >= 0 || b.highlightEndCol >= 0 +} + +// SetHighlightStyle sets the style function used for highlighting. +func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) { + b.highlightStyle = style +} + +// GetHighlightStyle returns the current highlight style function. +func (b *BaseHighlightable) GetHighlightStyle() CellStyler { + return b.highlightStyle +} + +// InitHighlight initializes the highlight fields with default values. +func (b *BaseHighlightable) InitHighlight() { + b.highlightStartLine = -1 + b.highlightStartCol = -1 + b.highlightEndLine = -1 + b.highlightEndCol = -1 + b.highlightStyle = LipglossStyleToCellStyler(lipgloss.NewStyle().Reverse(true)) +} + +// ApplyHighlight applies highlighting to a screen buffer. +// This should be called after drawing content to the buffer. +func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) { + if b.highlightStartLine < 0 { + return + } + + var ( + topMargin, topBorder, topPadding int + rightMargin, rightBorder, rightPadding int + bottomMargin, bottomBorder, bottomPadding int + leftMargin, leftBorder, leftPadding int + ) + if style != nil { + topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin() + topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(), + style.GetBorderRightSize(), + style.GetBorderBottomSize(), + style.GetBorderLeftSize() + topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding() + } + + // Calculate content area offsets + contentArea := image.Rectangle{ + Min: image.Point{ + X: leftMargin + leftBorder + leftPadding, + Y: topMargin + topBorder + topPadding, + }, + Max: image.Point{ + X: width - (rightMargin + rightBorder + rightPadding), + Y: height - (bottomMargin + bottomBorder + bottomPadding), + }, + } + + for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ { + if y >= buf.Height() { + break + } + + line := buf.Line(y) + + // Determine column range for this line + startCol := 0 + if y == b.highlightStartLine { + startCol = min(b.highlightStartCol, len(line)) + } + + endCol := len(line) + if y == b.highlightEndLine { + endCol = min(b.highlightEndCol, len(line)) + } + + // Track last non-empty position as we go + lastContentX := -1 + + // Single pass: check content and track last non-empty position + for x := startCol; x < endCol; x++ { + cell := line.At(x) + if cell == nil { + continue + } + + // Update last content position if non-empty + if cell.Content != "" && cell.Content != " " { + lastContentX = x + } + } + + // Only apply highlight up to last content position + highlightEnd := endCol + if lastContentX >= 0 { + highlightEnd = lastContentX + 1 + } else if lastContentX == -1 { + highlightEnd = startCol // No content on this line + } + + // Apply highlight style only to cells with content + for x := startCol; x < highlightEnd; x++ { + if !image.Pt(x, y).In(contentArea) { + continue + } + cell := line.At(x) + cell.Style = b.highlightStyle(cell.Style) + } + } +} + // StringItem is a simple string-based item with optional text wrapping. // It caches rendered content by width for efficient repeated rendering. // StringItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles. +// StringItem implements Highlightable for text selection/search highlighting. type StringItem struct { BaseFocusable + BaseHighlightable id string content string // Raw content string (may contain ANSI styles) wrap bool // Whether to wrap text @@ -88,24 +281,51 @@ type StringItem struct { cache map[int]string } +// CellStyler is a function that applies styles to UV cells. +type CellStyler = func(s uv.Style) uv.Style + +var noColor = lipgloss.NoColor{} + +// LipglossStyleToCellStyler converts a Lip Gloss style to a CellStyler function. +func LipglossStyleToCellStyler(lgStyle lipgloss.Style) CellStyler { + uvStyle := toUVStyle(lgStyle) + return func(s uv.Style) uv.Style { + if uvStyle.Fg != nil && lgStyle.GetForeground() != noColor { + s.Fg = uvStyle.Fg + } + if uvStyle.Bg != nil && lgStyle.GetBackground() != noColor { + s.Bg = uvStyle.Bg + } + s.Attrs |= uvStyle.Attrs + if uvStyle.Underline != 0 { + s.Underline = uvStyle.Underline + } + return s + } +} + // NewStringItem creates a new string item with the given ID and content. func NewStringItem(id, content string) *StringItem { - return &StringItem{ + s := &StringItem{ id: id, content: content, wrap: false, cache: make(map[int]string), } + s.InitHighlight() + return s } // NewWrappingStringItem creates a new string item that wraps text to fit width. func NewWrappingStringItem(id, content string) *StringItem { - return &StringItem{ + s := &StringItem{ id: id, content: content, wrap: true, cache: make(map[int]string), } + s.InitHighlight() + return s } // WithFocusStyles sets the focus and blur styles for the string item. @@ -153,6 +373,7 @@ func (s *StringItem) Height(width int) int { // Draw implements Item and uv.Drawable. func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) { width := area.Dx() + height := area.Dy() // Check cache first content, ok := s.cache[width] @@ -167,21 +388,41 @@ func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) { } // Apply focus/blur styling if configured - if style := s.CurrentStyle(); style != nil { + style := s.CurrentStyle() + if style != nil { content = style.Width(width).Render(content) } - // Draw the styled string + // Create temp buffer to draw content with highlighting + tempBuf := uv.NewScreenBuffer(width, height) + + // Draw content to temp buffer first styled := uv.NewStyledString(content) - styled.Draw(scr, area) + styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) + + // Apply highlighting if active + s.ApplyHighlight(&tempBuf, width, height, style) + + // Copy temp buffer to actual screen at the target area + tempBuf.Draw(scr, area) +} + +// SetHighlight implements Highlightable and extends BaseHighlightable. +// Clears the cache when highlight changes. +func (s *StringItem) SetHighlight(startLine, startCol, endLine, endCol int) { + s.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) + // Clear cache when highlight changes + s.cache = make(map[int]string) } // MarkdownItem renders markdown content using Glamour. // It caches all rendered content by width for efficient repeated rendering. // The wrap width is capped at 120 cells by default to ensure readable line lengths. // MarkdownItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles. +// MarkdownItem implements Highlightable for text selection/search highlighting. type MarkdownItem struct { BaseFocusable + BaseHighlightable id string markdown string // Raw markdown content styleConfig *ansi.StyleConfig // Optional style configuration @@ -204,7 +445,7 @@ func NewMarkdownItem(id, markdown string) *MarkdownItem { maxWidth: DefaultMarkdownMaxWidth, cache: make(map[int]string), } - + m.InitHighlight() return m } @@ -248,16 +489,27 @@ func (m *MarkdownItem) Height(width int) int { // Draw implements Item and uv.Drawable. func (m *MarkdownItem) Draw(scr uv.Screen, area uv.Rectangle) { width := area.Dx() + height := area.Dy() rendered := m.renderMarkdown(width) // Apply focus/blur styling if configured - if style := m.CurrentStyle(); style != nil { + style := m.CurrentStyle() + if style != nil { rendered = style.Render(rendered) } - // Draw the rendered markdown + // Create temp buffer to draw content with highlighting + tempBuf := uv.NewScreenBuffer(width, height) + + // Draw the rendered markdown to temp buffer styled := uv.NewStyledString(rendered) - styled.Draw(scr, area) + styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) + + // Apply highlighting if active + m.ApplyHighlight(&tempBuf, width, height, style) + + // Copy temp buffer to actual screen at the target area + tempBuf.Draw(scr, area) } // renderMarkdown renders the markdown content at the given width, using cache if available. @@ -302,6 +554,14 @@ func (m *MarkdownItem) renderMarkdown(width int) string { return rendered } +// SetHighlight implements Highlightable and extends BaseHighlightable. +// Clears the cache when highlight changes. +func (m *MarkdownItem) SetHighlight(startLine, startCol, endLine, endCol int) { + m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) + // Clear cache when highlight changes + m.cache = make(map[int]string) +} + // Gap is a 1-line spacer item used to add gaps between items. var Gap = NewSpacerItem("spacer-gap", 1) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 0b87aca1790776666a97cfb7cb76fe83933375e2..7b2c72ecfc35262bbf53c33c43fb859b5ceb3068 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -32,6 +32,15 @@ type List struct { // Viewport state offset int // Scroll offset in lines from top + // Mouse state + mouseDown bool + mouseDownItem string // Item ID where mouse was pressed + mouseDownX int // X position in item content (character offset) + mouseDownY int // Y position in item (line offset) + mouseDragItem string // Current item being dragged over + mouseDragX int // Current X in item content + mouseDragY int // Current Y in item + // Dirty tracking dirty bool dirtyItems map[string]bool @@ -362,6 +371,16 @@ func (l *List) SetSize(width, height int) { } } +// Height returns the current viewport height. +func (l *List) Height() int { + return l.height +} + +// Width returns the current viewport width. +func (l *List) Width() int { + return l.width +} + // GetSize returns the current viewport size. func (l *List) GetSize() (int, int) { return l.width, l.height @@ -569,8 +588,8 @@ func (l *List) Blur() { l.blurSelectedItem() } -// IsFocused returns whether the list is focused. -func (l *List) IsFocused() bool { +// Focused returns whether the list is focused. +func (l *List) Focused() bool { return l.focused } @@ -612,16 +631,44 @@ func (l *List) SetSelectedIndex(idx int) { l.SetSelected(l.items[idx].ID()) } -// SelectNext selects the next item in the list (wraps to beginning). +// SelectFirst selects the first item in the list. +func (l *List) SelectFirst() { + l.SetSelectedIndex(0) +} + +// SelectLast selects the last item in the list. +func (l *List) SelectLast() { + l.SetSelectedIndex(len(l.items) - 1) +} + +// SelectNextWrap selects the next item in the list (wraps to beginning). +// When the list is focused, skips non-focusable items. +func (l *List) SelectNextWrap() { + l.selectNext(true) +} + +// SelectNext selects the next item in the list (no wrap). // When the list is focused, skips non-focusable items. func (l *List) SelectNext() { + l.selectNext(false) +} + +func (l *List) selectNext(wrap bool) { if len(l.items) == 0 { return } startIdx := l.selectedIdx for i := 0; i < len(l.items); i++ { - nextIdx := (startIdx + 1 + i) % len(l.items) + var nextIdx int + if wrap { + nextIdx = (startIdx + 1 + i) % len(l.items) + } else { + nextIdx = startIdx + 1 + i + if nextIdx >= len(l.items) { + return + } + } // If list is focused and item is not focusable, skip it if l.focused { @@ -632,21 +679,38 @@ func (l *List) SelectNext() { // Select and scroll to this item l.SetSelected(l.items[nextIdx].ID()) - l.ScrollToSelected() return } } -// SelectPrev selects the previous item in the list (wraps to end). +// SelectPrevWrap selects the previous item in the list (wraps to end). +// When the list is focused, skips non-focusable items. +func (l *List) SelectPrevWrap() { + l.selectPrev(true) +} + +// SelectPrev selects the previous item in the list (no wrap). // When the list is focused, skips non-focusable items. func (l *List) SelectPrev() { + l.selectPrev(false) +} + +func (l *List) selectPrev(wrap bool) { if len(l.items) == 0 { return } startIdx := l.selectedIdx for i := 0; i < len(l.items); i++ { - prevIdx := (startIdx - 1 - i + len(l.items)) % len(l.items) + var prevIdx int + if wrap { + prevIdx = (startIdx - 1 - i + len(l.items)) % len(l.items) + } else { + prevIdx = startIdx - 1 - i + if prevIdx < 0 { + return + } + } // If list is focused and item is not focusable, skip it if l.focused { @@ -657,7 +721,6 @@ func (l *List) SelectPrev() { // Select and scroll to this item l.SetSelected(l.items[prevIdx].ID()) - l.ScrollToSelected() return } } @@ -754,6 +817,27 @@ func (l *List) TotalHeight() int { return l.totalHeight } +// SelectedItemInView returns true if the selected item is currently visible in the viewport. +func (l *List) SelectedItemInView() bool { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return false + } + + // Get selected item ID and position + item := l.items[l.selectedIdx] + pos, ok := l.itemPositions[item.ID()] + if !ok { + return false + } + + // Check if item is within viewport bounds + viewportStart := l.offset + viewportEnd := l.offset + l.height + + // Item is visible if any part of it overlaps with the viewport + return pos.startLine < viewportEnd && (pos.startLine+pos.height) > viewportStart +} + // clampOffset ensures offset is within valid bounds. func (l *List) clampOffset() { maxOffset := l.totalHeight - l.height @@ -793,3 +877,279 @@ func (l *List) blurSelectedItem() { l.dirtyItems[item.ID()] = true } } + +// HandleMouseDown handles mouse button press events. +// x and y are viewport-relative coordinates (0,0 = top-left of visible area). +// Returns true if the event was handled. +func (l *List) HandleMouseDown(x, y int) bool { + l.ensureBuilt() + + // Convert viewport y to master buffer y + bufferY := y + l.offset + + // Find which item was clicked + itemID, itemY := l.findItemAtPosition(bufferY) + if itemID == "" { + return false + } + + // Calculate x position within item content + // For now, x is just the viewport x coordinate + // Items can interpret this as character offset in their content + + l.mouseDown = true + l.mouseDownItem = itemID + l.mouseDownX = x + l.mouseDownY = itemY + l.mouseDragItem = itemID + l.mouseDragX = x + l.mouseDragY = itemY + + // Select the clicked item + if idx, ok := l.indexMap[itemID]; ok { + l.SetSelectedIndex(idx) + } + + return true +} + +// HandleMouseDrag handles mouse drag events during selection. +// x and y are viewport-relative coordinates. +// Returns true if the event was handled. +func (l *List) HandleMouseDrag(x, y int) bool { + if !l.mouseDown { + return false + } + + l.ensureBuilt() + + // Convert viewport y to master buffer y + bufferY := y + l.offset + + // Find which item we're dragging over + itemID, itemY := l.findItemAtPosition(bufferY) + if itemID == "" { + return false + } + + l.mouseDragItem = itemID + l.mouseDragX = x + l.mouseDragY = itemY + + // Update highlight if item supports it + l.updateHighlight() + + return true +} + +// HandleMouseUp handles mouse button release events. +// Returns true if the event was handled. +func (l *List) HandleMouseUp(x, y int) bool { + if !l.mouseDown { + return false + } + + l.mouseDown = false + + // Final highlight update + l.updateHighlight() + + return true +} + +// ClearHighlight clears any active text highlighting. +func (l *List) ClearHighlight() { + for _, item := range l.items { + if h, ok := item.(Highlightable); ok { + h.SetHighlight(-1, -1, -1, -1) + l.dirtyItems[item.ID()] = true + } + } +} + +// findItemAtPosition finds the item at the given master buffer y coordinate. +// Returns the item ID and the y offset within that item. +func (l *List) findItemAtPosition(bufferY int) (itemID string, itemY int) { + if bufferY < 0 || bufferY >= l.totalHeight { + return "", 0 + } + + // Linear search through items to find which one contains this y + // This could be optimized with binary search if needed + for _, item := range l.items { + pos, ok := l.itemPositions[item.ID()] + if !ok { + continue + } + + if bufferY >= pos.startLine && bufferY < pos.startLine+pos.height { + return item.ID(), bufferY - pos.startLine + } + } + + return "", 0 +} + +// updateHighlight updates the highlight range for highlightable items. +// Supports highlighting across multiple items and respects drag direction. +func (l *List) updateHighlight() { + if l.mouseDownItem == "" { + return + } + + // Get start and end item indices + downItemIdx := l.indexMap[l.mouseDownItem] + dragItemIdx := l.indexMap[l.mouseDragItem] + + // Determine selection direction + draggingDown := dragItemIdx > downItemIdx || + (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) || + (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX) + + // Determine actual start and end based on direction + var startItemIdx, endItemIdx int + var startLine, startCol, endLine, endCol int + + if draggingDown { + // Normal forward selection + startItemIdx = downItemIdx + endItemIdx = dragItemIdx + startLine = l.mouseDownY + startCol = l.mouseDownX + endLine = l.mouseDragY + endCol = l.mouseDragX + } else { + // Backward selection (dragging up) + startItemIdx = dragItemIdx + endItemIdx = downItemIdx + startLine = l.mouseDragY + startCol = l.mouseDragX + endLine = l.mouseDownY + endCol = l.mouseDownX + } + + // Clear all highlights first + for _, item := range l.items { + if h, ok := item.(Highlightable); ok { + h.SetHighlight(-1, -1, -1, -1) + l.dirtyItems[item.ID()] = true + } + } + + // Highlight all items in range + for idx := startItemIdx; idx <= endItemIdx; idx++ { + item, ok := l.items[idx].(Highlightable) + if !ok { + continue + } + + if idx == startItemIdx && idx == endItemIdx { + // Single item selection + item.SetHighlight(startLine, startCol, endLine, endCol) + } else if idx == startItemIdx { + // First item - from start position to end of item + pos := l.itemPositions[l.items[idx].ID()] + item.SetHighlight(startLine, startCol, pos.height-1, 9999) // 9999 = end of line + } else if idx == endItemIdx { + // Last item - from start of item to end position + item.SetHighlight(0, 0, endLine, endCol) + } else { + // Middle item - fully highlighted + pos := l.itemPositions[l.items[idx].ID()] + item.SetHighlight(0, 0, pos.height-1, 9999) + } + + l.dirtyItems[l.items[idx].ID()] = true + } +} + +// GetHighlightedText returns the plain text content of all highlighted regions +// across items, without any styling. Returns empty string if no highlights exist. +func (l *List) GetHighlightedText() string { + l.ensureBuilt() + + if l.masterBuffer == nil { + return "" + } + + var result strings.Builder + + // Iterate through items to find highlighted ones + for _, item := range l.items { + h, ok := item.(Highlightable) + if !ok { + continue + } + + startLine, startCol, endLine, endCol := h.GetHighlight() + if startLine < 0 { + continue + } + + pos, ok := l.itemPositions[item.ID()] + if !ok { + continue + } + + // Extract text from highlighted region in master buffer + for y := startLine; y <= endLine && y < pos.height; y++ { + bufferY := pos.startLine + y + if bufferY >= l.masterBuffer.Height() { + break + } + + line := l.masterBuffer.Line(bufferY) + + // Determine column range for this line + colStart := 0 + if y == startLine { + colStart = startCol + } + + colEnd := len(line) + if y == endLine { + colEnd = min(endCol, len(line)) + } + + // Track last non-empty position to trim trailing spaces + lastContentX := -1 + for x := colStart; x < colEnd && x < len(line); x++ { + cell := line.At(x) + if cell == nil || cell.IsZero() { + continue + } + if cell.Content != "" && cell.Content != " " { + lastContentX = x + } + } + + // Extract text from cells using String() method, up to last content + endX := colEnd + if lastContentX >= 0 { + endX = lastContentX + 1 + } + + for x := colStart; x < endX && x < len(line); x++ { + cell := line.At(x) + if cell == nil || cell.IsZero() { + continue + } + result.WriteString(cell.String()) + } + + // Add newline between lines (but not after the last line) + if y < endLine && y < pos.height-1 { + result.WriteRune('\n') + } + } + + // Add newline between items if there are more highlighted items + if result.Len() > 0 { + result.WriteRune('\n') + } + } + + // Trim trailing newline if present + text := result.String() + return strings.TrimSuffix(text, "\n") +} diff --git a/internal/ui/list/list_test.go b/internal/ui/list/list_test.go index 5ec228e698fae6b52c6c44e981348d964fbd46ca..e4f6dcff6714af0e55dc7397809235f70b386766 100644 --- a/internal/ui/list/list_test.go +++ b/internal/ui/list/list_test.go @@ -213,7 +213,7 @@ func TestListFocus(t *testing.T) { // Focus the list l.Focus() - if !l.IsFocused() { + if !l.Focused() { t.Error("expected list to be focused") } @@ -236,7 +236,7 @@ func TestListFocus(t *testing.T) { // Blur the list l.Blur() - if l.IsFocused() { + if l.Focused() { t.Error("expected list to be blurred") } } From a52e0306c3603b969daed15a59cc88683fac9e8c Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 2 Dec 2025 11:56:58 -0500 Subject: [PATCH 021/335] fix(ui): hide cursor when editor is not visible --- internal/ui/model/ui.go | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 7a3b2847fb3b8c659d69fe4645cd1228ee881c88..ba2cdcf9ce92291c68f07b142501a2c0688f44f1 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -386,19 +386,28 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { } } +// Cursor returns the cursor position and properties for the UI model. It +// returns nil if the cursor should not be shown. +func (m *UI) Cursor() *tea.Cursor { + if m.layout.editor.Dy() <= 0 { + // Don't show cursor if editor is not visible + return nil + } + if m.focus == uiFocusEditor && m.textarea.Focused() { + cur := m.textarea.Cursor() + cur.X++ // Adjust for app margins + cur.Y += m.layout.editor.Min.Y + return cur + } + return nil +} + // View renders the UI model's view. func (m *UI) View() tea.View { var v tea.View v.AltScreen = true v.BackgroundColor = m.com.Styles.Background - - layout := generateLayout(m, m.width, m.height) - if m.focus == uiFocusEditor && m.textarea.Focused() { - cur := m.textarea.Cursor() - cur.X++ // Adjust for app margins - cur.Y += layout.editor.Min.Y - v.Cursor = cur - } + v.Cursor = m.Cursor() // TODO: Switch to lipgloss.Canvas when available canvas := uv.NewScreenBuffer(m.width, m.height) From f0942a72d202fb74ef0666a1b26ac3f1443f1aa2 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 2 Dec 2025 12:02:26 -0500 Subject: [PATCH 022/335] fix(ui): change pointer receivers to value receivers --- internal/ui/model/ui.go | 56 ++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index ba2cdcf9ce92291c68f07b142501a2c0688f44f1..51ccb9f584cc70863a1bf21a09a2bbda742970de 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -162,7 +162,7 @@ func New(com *common.Common) *UI { } // Init initializes the UI model. -func (m *UI) Init() tea.Cmd { +func (m UI) Init() tea.Cmd { if m.QueryVersion { return tea.RequestTerminalVersion } @@ -170,7 +170,7 @@ func (m *UI) Init() tea.Cmd { } // Update handles updates to the UI model. -func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.EnvMsg: @@ -297,73 +297,65 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { // Draw implements [tea.Layer] and draws the UI model. func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { - layout := generateLayout(m, area.Dx(), area.Dy()) - - // Update cached layout and component sizes if needed. - if m.layout != layout { - m.layout = layout - m.updateSize() - } - // Clear the screen first screen.Clear(scr) switch m.state { case uiConfigure: header := uv.NewStyledString(m.header) - header.Draw(scr, layout.header) + header.Draw(scr, m.layout.header) - mainView := lipgloss.NewStyle().Width(layout.main.Dx()). - Height(layout.main.Dy()). + mainView := lipgloss.NewStyle().Width(m.layout.main.Dx()). + Height(m.layout.main.Dy()). Background(lipgloss.ANSIColor(rand.Intn(256))). Render(" Configure ") main := uv.NewStyledString(mainView) - main.Draw(scr, layout.main) + main.Draw(scr, m.layout.main) case uiInitialize: header := uv.NewStyledString(m.header) - header.Draw(scr, layout.header) + header.Draw(scr, m.layout.header) main := uv.NewStyledString(m.initializeView()) - main.Draw(scr, layout.main) + main.Draw(scr, m.layout.main) case uiLanding: header := uv.NewStyledString(m.header) - header.Draw(scr, layout.header) + header.Draw(scr, m.layout.header) main := uv.NewStyledString(m.landingView()) - main.Draw(scr, layout.main) + main.Draw(scr, m.layout.main) editor := uv.NewStyledString(m.textarea.View()) - editor.Draw(scr, layout.editor) + editor.Draw(scr, m.layout.editor) case uiChat: header := uv.NewStyledString(m.header) - header.Draw(scr, layout.header) - m.drawSidebar(scr, layout.sidebar) + header.Draw(scr, m.layout.header) + m.drawSidebar(scr, m.layout.sidebar) - m.chat.Draw(scr, layout.main) + m.chat.Draw(scr, m.layout.main) editor := uv.NewStyledString(m.textarea.View()) - editor.Draw(scr, layout.editor) + editor.Draw(scr, m.layout.editor) case uiChatCompact: header := uv.NewStyledString(m.header) - header.Draw(scr, layout.header) + header.Draw(scr, m.layout.header) - mainView := lipgloss.NewStyle().Width(layout.main.Dx()). - Height(layout.main.Dy()). + mainView := lipgloss.NewStyle().Width(m.layout.main.Dx()). + Height(m.layout.main.Dy()). Background(lipgloss.ANSIColor(rand.Intn(256))). Render(" Compact Chat Messages ") main := uv.NewStyledString(mainView) - main.Draw(scr, layout.main) + main.Draw(scr, m.layout.main) editor := uv.NewStyledString(m.textarea.View()) - editor.Draw(scr, layout.editor) + editor.Draw(scr, m.layout.editor) } // Add help layer help := uv.NewStyledString(m.help.View(m)) - help.Draw(scr, layout.help) + help.Draw(scr, m.layout.help) // Debugging rendering (visually see when the tui rerenders) if os.Getenv("CRUSH_UI_DEBUG") == "true" { @@ -403,7 +395,7 @@ func (m *UI) Cursor() *tea.Cursor { } // View renders the UI model's view. -func (m *UI) View() tea.View { +func (m UI) View() tea.View { var v tea.View v.AltScreen = true v.BackgroundColor = m.com.Styles.Background @@ -434,7 +426,7 @@ func (m *UI) View() tea.View { } // ShortHelp implements [help.KeyMap]. -func (m *UI) ShortHelp() []key.Binding { +func (m UI) ShortHelp() []key.Binding { var binds []key.Binding k := &m.keyMap @@ -481,7 +473,7 @@ func (m *UI) ShortHelp() []key.Binding { } // FullHelp implements [help.KeyMap]. -func (m *UI) FullHelp() [][]key.Binding { +func (m UI) FullHelp() [][]key.Binding { var binds [][]key.Binding k := &m.keyMap help := k.Help From 84bbd5b7587ad538c0b67f27ad9b48934a149cf5 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 2 Dec 2025 13:59:06 -0500 Subject: [PATCH 023/335] fix(ui): change UI model receiver to pointer back We need to fix this at some point --- internal/ui/model/ui.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 51ccb9f584cc70863a1bf21a09a2bbda742970de..81475f35c616583bb098c3c570b627f5392cfb60 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -162,15 +162,16 @@ func New(com *common.Common) *UI { } // Init initializes the UI model. -func (m UI) Init() tea.Cmd { +func (m *UI) Init() tea.Cmd { + var cmds []tea.Cmd if m.QueryVersion { - return tea.RequestTerminalVersion + cmds = append(cmds, tea.RequestTerminalVersion) } - return nil + return tea.Batch(cmds...) } // Update handles updates to the UI model. -func (m UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.EnvMsg: @@ -395,7 +396,7 @@ func (m *UI) Cursor() *tea.Cursor { } // View renders the UI model's view. -func (m UI) View() tea.View { +func (m *UI) View() tea.View { var v tea.View v.AltScreen = true v.BackgroundColor = m.com.Styles.Background @@ -426,7 +427,7 @@ func (m UI) View() tea.View { } // ShortHelp implements [help.KeyMap]. -func (m UI) ShortHelp() []key.Binding { +func (m *UI) ShortHelp() []key.Binding { var binds []key.Binding k := &m.keyMap @@ -473,7 +474,7 @@ func (m UI) ShortHelp() []key.Binding { } // FullHelp implements [help.KeyMap]. -func (m UI) FullHelp() [][]key.Binding { +func (m *UI) FullHelp() [][]key.Binding { var binds [][]key.Binding k := &m.keyMap help := k.Help From 063398841cb5bcca61d7a6b7d6a08cd8146b26be Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 2 Dec 2025 15:35:02 -0500 Subject: [PATCH 024/335] feat(ui): initial chat ui implementation --- internal/ui/common/markdown.go | 27 +++ internal/ui/list/list.go | 24 +-- internal/ui/model/chat.go | 331 +++++++++++++++++++++++++++------ internal/ui/model/keys.go | 29 +++ internal/ui/model/ui.go | 145 ++++++++++----- internal/ui/styles/styles.go | 42 ++++- 6 files changed, 472 insertions(+), 126 deletions(-) create mode 100644 internal/ui/common/markdown.go diff --git a/internal/ui/common/markdown.go b/internal/ui/common/markdown.go new file mode 100644 index 0000000000000000000000000000000000000000..3c90c2dc1582160c919f4fe432e78642a0a2c97d --- /dev/null +++ b/internal/ui/common/markdown.go @@ -0,0 +1,27 @@ +package common + +import ( + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/glamour/v2" + gstyles "github.com/charmbracelet/glamour/v2/styles" +) + +// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with +// the given styles and width. +func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer { + r, _ := glamour.NewTermRenderer( + glamour.WithStyles(t.Markdown), + glamour.WithWordWrap(width), + ) + return r +} + +// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors +// (plain text with structure) and the given width. +func PlainMarkdownRenderer(width int) *glamour.TermRenderer { + r, _ := glamour.NewTermRenderer( + glamour.WithStyles(gstyles.ASCIIStyleConfig), + glamour.WithWordWrap(width), + ) + return r +} diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 7b2c72ecfc35262bbf53c33c43fb859b5ceb3068..8928cb0f011fffe2e1f70c3a34b7a6bad6212f67 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -5,6 +5,7 @@ import ( uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/ultraviolet/screen" + "github.com/charmbracelet/x/exp/ordered" ) // List is a scrollable list component that implements uv.Drawable. @@ -160,7 +161,7 @@ func (l *List) renderViewport() string { for i := range lines { lines[i] = emptyLine } - return strings.Join(lines, "\r\n") + return strings.Join(lines, "\n") } if srcEndY > len(buf.Lines) { srcEndY = len(buf.Lines) @@ -182,7 +183,7 @@ func (l *List) renderViewport() string { lines[lineIdx] = emptyLine } - return strings.Join(lines, "\r\n") + return strings.Join(lines, "\n") } // drawViewport draws the visible portion from master buffer to target screen. @@ -199,18 +200,18 @@ func (l *List) drawViewport(scr uv.Screen, area uv.Rectangle) { srcEndY := l.offset + area.Dy() // Clamp to actual buffer bounds - if srcStartY >= len(buf.Lines) { + if srcStartY >= buf.Height() { screen.ClearArea(scr, area) return } - if srcEndY > len(buf.Lines) { - srcEndY = len(buf.Lines) + if srcEndY > buf.Height() { + srcEndY = buf.Height() } // Copy visible lines to target screen destY := area.Min.Y for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ { - line := buf.Lines[srcY] + line := buf.Line(srcY) destX := area.Min.X for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ { @@ -840,16 +841,7 @@ func (l *List) SelectedItemInView() bool { // clampOffset ensures offset is within valid bounds. func (l *List) clampOffset() { - maxOffset := l.totalHeight - l.height - if maxOffset < 0 { - maxOffset = 0 - } - - if l.offset < 0 { - l.offset = 0 - } else if l.offset > maxOffset { - l.offset = maxOffset - } + l.offset = ordered.Clamp(l.offset, 0, l.totalHeight-l.height) } // focusSelectedItem focuses the currently selected item if it's focusable. diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 74695624ce731b415f8c9a3f565f87541e62dfaf..28272d8b6356ebd7de39d888f6de886b1c2e3b0e 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -1,86 +1,295 @@ package model import ( - "charm.land/bubbles/v2/key" + "fmt" + "strings" + tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" + "github.com/google/uuid" +) + +// ChatAnimItem represents a chat animation item in the chat UI. +type ChatAnimItem struct { + list.BaseFocusable + anim *anim.Anim +} + +var ( + _ list.Item = (*ChatAnimItem)(nil) + _ list.Focusable = (*ChatAnimItem)(nil) +) + +// NewChatAnimItem creates a new instance of [ChatAnimItem]. +func NewChatAnimItem(a *anim.Anim) *ChatAnimItem { + m := new(ChatAnimItem) + return m +} + +// Init initializes the chat animation item. +func (c *ChatAnimItem) Init() tea.Cmd { + return c.anim.Init() +} + +// Step advances the animation by one step. +func (c *ChatAnimItem) Step() tea.Cmd { + return c.anim.Step() +} + +// SetLabel sets the label for the animation item. +func (c *ChatAnimItem) SetLabel(label string) { + c.anim.SetLabel(label) +} + +// Draw implements list.Item. +func (c *ChatAnimItem) Draw(scr uv.Screen, area uv.Rectangle) { + styled := uv.NewStyledString(c.anim.View()) + styled.Draw(scr, area) +} + +// Height implements list.Item. +func (c *ChatAnimItem) Height(int) int { + return 1 +} + +// ID implements list.Item. +func (c *ChatAnimItem) ID() string { + return "anim" +} + +// ChatNoContentItem represents a chat item with no content. +type ChatNoContentItem struct { + *list.StringItem +} + +// NewChatNoContentItem creates a new instance of [ChatNoContentItem]. +func NewChatNoContentItem(t *styles.Styles, id string) *ChatNoContentItem { + c := new(ChatNoContentItem) + c.StringItem = list.NewStringItem(id, "No message content"). + WithFocusStyles(&t.Chat.NoContentMessage, &t.Chat.NoContentMessage) + return c +} + +// ChatMessageItem represents a chat message item in the chat UI. +type ChatMessageItem struct { + list.BaseFocusable + list.BaseHighlightable + + item list.Item + msg message.Message +} + +var ( + _ list.Item = (*ChatMessageItem)(nil) + _ list.Focusable = (*ChatMessageItem)(nil) + _ list.Highlightable = (*ChatMessageItem)(nil) ) -// ChatKeyMap defines key bindings for the chat model. -type ChatKeyMap struct { - NewSession key.Binding - AddAttachment key.Binding - Cancel key.Binding - Tab key.Binding - Details key.Binding -} - -// DefaultChatKeyMap returns the default key bindings for the chat model. -func DefaultChatKeyMap() ChatKeyMap { - return ChatKeyMap{ - 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"), - ), +// NewChatMessageItem creates a new instance of [ChatMessageItem]. +func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem { + c := new(ChatMessageItem) + + switch msg.Role { + case message.User: + item := list.NewMarkdownItem(msg.ID, msg.Content().String()). + WithFocusStyles(&t.Chat.UserMessageFocused, &t.Chat.UserMessageBlurred) + item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection)) + // TODO: Add attachments + c.item = item + default: + var thinkingContent string + content := msg.Content().String() + thinking := msg.IsThinking() + finished := msg.IsFinished() + finishedData := msg.FinishPart() + reasoningContent := msg.ReasoningContent() + reasoningThinking := strings.TrimSpace(reasoningContent.Thinking) + + if finished && content == "" && finishedData.Reason == message.FinishReasonError { + tag := t.Chat.ErrorTag.Render("ERROR") + title := t.Chat.ErrorTitle.Render(finishedData.Message) + details := t.Chat.ErrorDetails.Render(finishedData.Details) + errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details) + + item := list.NewStringItem(msg.ID, errContent). + WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred) + + c.item = item + + return c + } + + if thinking || reasoningThinking != "" { + // TODO: animation item? + // TODO: thinking item + thinkingContent = reasoningThinking + } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled { + content = "*Canceled*" + } + + var parts []string + if thinkingContent != "" { + parts = append(parts, thinkingContent) + } + + if content != "" { + if len(parts) > 0 { + parts = append(parts, "") + } + parts = append(parts, content) + } + + item := list.NewMarkdownItem(msg.ID, strings.Join(parts, "\n")). + WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred) + item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection)) + + c.item = item } + + return c +} + +// Draw implements list.Item. +func (c *ChatMessageItem) Draw(scr uv.Screen, area uv.Rectangle) { + c.item.Draw(scr, area) +} + +// Height implements list.Item. +func (c *ChatMessageItem) Height(width int) int { + return c.item.Height(width) +} + +// ID implements list.Item. +func (c *ChatMessageItem) ID() string { + return c.item.ID() +} + +// Chat represents the chat UI model that handles chat interactions and +// messages. +type Chat struct { + com *common.Common + list *list.List +} + +// NewChat creates a new instance of [Chat] that handles chat interactions and +// messages. +func NewChat(com *common.Common) *Chat { + l := list.New() + return &Chat{ + com: com, + list: l, + } +} + +// Height returns the height of the chat view port. +func (m *Chat) Height() int { + return m.list.Height() +} + +// Draw renders the chat UI component to the screen and the given area. +func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) { + m.list.Draw(scr, area) +} + +// SetSize sets the size of the chat view port. +func (m *Chat) SetSize(width, height int) { + m.list.SetSize(width, height) +} + +// Len returns the number of items in the chat list. +func (m *Chat) Len() int { + return m.list.Len() } -// ChatModel represents the chat UI model. -type ChatModel struct { - app *app.App - com *common.Common +// PrependItem prepends a new item to the chat list. +func (m *Chat) PrependItem(item list.Item) { + m.list.PrependItem(item) +} - keyMap ChatKeyMap +// AppendMessage appends a new message item to the chat list. +func (m *Chat) AppendMessage(msg message.Message) { + if msg.ID == "" { + m.AppendItem(NewChatNoContentItem(m.com.Styles, uuid.NewString())) + } else { + m.AppendItem(NewChatMessageItem(m.com.Styles, msg)) + } } -// NewChatModel creates a new instance of ChatModel. -func NewChatModel(com *common.Common, app *app.App) *ChatModel { - return &ChatModel{ - app: app, - com: com, - keyMap: DefaultChatKeyMap(), +// AppendItem appends a new item to the chat list. +func (m *Chat) AppendItem(item list.Item) { + if m.Len() > 0 { + // Always add a spacer between messages + m.list.AppendItem(list.NewSpacerItem(uuid.NewString(), 1)) } + m.list.AppendItem(item) +} + +// Focus sets the focus state of the chat component. +func (m *Chat) Focus() { + m.list.Focus() +} + +// Blur removes the focus state from the chat component. +func (m *Chat) Blur() { + m.list.Blur() +} + +// ScrollToTop scrolls the chat view to the top. +func (m *Chat) ScrollToTop() { + m.list.ScrollToTop() +} + +// ScrollToBottom scrolls the chat view to the bottom. +func (m *Chat) ScrollToBottom() { + m.list.ScrollToBottom() +} + +// ScrollBy scrolls the chat view by the given number of line deltas. +func (m *Chat) ScrollBy(lines int) { + m.list.ScrollBy(lines) +} + +// ScrollToSelected scrolls the chat view to the selected item. +func (m *Chat) ScrollToSelected() { + m.list.ScrollToSelected() +} + +// SelectedItemInView returns whether the selected item is currently in view. +func (m *Chat) SelectedItemInView() bool { + return m.list.SelectedItemInView() +} + +// SetSelectedIndex sets the selected message index in the chat list. +func (m *Chat) SetSelectedIndex(index int) { + m.list.SetSelectedIndex(index) } -// Init initializes the chat model. -func (m *ChatModel) Init() tea.Cmd { - return nil +// SelectPrev selects the previous message in the chat list. +func (m *Chat) SelectPrev() { + m.list.SelectPrev() } -// Update handles incoming messages and updates the chat model state. -func (m *ChatModel) Update(msg tea.Msg) (*ChatModel, tea.Cmd) { - // Handle messages here - return m, nil +// SelectNext selects the next message in the chat list. +func (m *Chat) SelectNext() { + m.list.SelectNext() } -// View renders the chat model's view. -func (m *ChatModel) View() string { - return "Chat Model View" +// HandleMouseDown handles mouse down events for the chat component. +func (m *Chat) HandleMouseDown(x, y int) { + m.list.HandleMouseDown(x, y) } -// ShortHelp returns a brief help view for the chat model. -func (m *ChatModel) ShortHelp() []key.Binding { - return nil +// HandleMouseUp handles mouse up events for the chat component. +func (m *Chat) HandleMouseUp(x, y int) { + m.list.HandleMouseUp(x, y) } -// FullHelp returns a detailed help view for the chat model. -func (m *ChatModel) FullHelp() [][]key.Binding { - return nil +// HandleMouseDrag handles mouse drag events for the chat component. +func (m *Chat) HandleMouseDrag(x, y int) { + m.list.HandleMouseDrag(x, y) } diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index 4acf010512b64de707eb6716ba574c885604276d..143f1d623828b56baa3fd43b86ca74ea2fbd82b9 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -17,6 +17,14 @@ type KeyMap struct { DeleteAllAttachments key.Binding } + Chat struct { + NewSession key.Binding + AddAttachment key.Binding + Cancel key.Binding + Tab key.Binding + Details key.Binding + } + Initialize struct { Yes, No, @@ -106,6 +114,27 @@ func DefaultKeyMap() KeyMap { key.WithHelp("ctrl+r+r", "delete all attachments"), ) + km.Chat.NewSession = key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl+n", "new session"), + ) + km.Chat.AddAttachment = key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "add attachment"), + ) + km.Chat.Cancel = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "cancel"), + ) + km.Chat.Tab = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "change focus"), + ) + km.Chat.Details = key.NewBinding( + key.WithKeys("ctrl+d"), + key.WithHelp("ctrl+d", "toggle details"), + ) + km.Initialize.Yes = key.NewBinding( key.WithKeys("y", "Y"), key.WithHelp("y", "yes"), diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 81475f35c616583bb098c3c570b627f5392cfb60..d11f25149c4bd7f97e0c256ee232042aed8a3634 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2,7 +2,6 @@ package model import ( "context" - "fmt" "image" "math/rand" "os" @@ -22,7 +21,6 @@ import ( "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" - "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/logo" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/version" @@ -77,7 +75,6 @@ type UI struct { keyMap KeyMap keyenh tea.KeyboardEnhancementsMsg - chat *list.List dialog *dialog.Overlay help help.Model @@ -100,6 +97,9 @@ type UI struct { readyPlaceholder string workingPlaceholder string + // Chat components + chat *Chat + // onboarding state onboarding struct { yesInitializeSelected bool @@ -113,6 +113,9 @@ type UI struct { // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string + + // Canvas for rendering + canvas *uv.ScreenBuffer } // New creates a new instance of the [UI] model. @@ -125,7 +128,11 @@ func New(com *common.Common) *UI { ta.SetVirtualCursor(false) ta.Focus() - l := list.New() + ch := NewChat(com) + + // TODO: Switch to lipgloss.Canvas when available + canvas := uv.NewScreenBuffer(0, 0) + canvas.Method = ansi.GraphemeWidth ui := &UI{ com: com, @@ -135,7 +142,8 @@ func New(com *common.Common) *UI { focus: uiFocusNone, state: uiConfigure, textarea: ta, - chat: l, + chat: ch, + canvas: &canvas, } // set onboarding state defaults @@ -167,6 +175,10 @@ func (m *UI) Init() tea.Cmd { if m.QueryVersion { cmds = append(cmds, tea.RequestTerminalVersion) } + allSessions, _ := m.com.App.Sessions.List(context.Background()) + if len(allSessions) > 0 { + cmds = append(cmds, m.loadSession(allSessions[0].ID)) + } return tea.Batch(cmds...) } @@ -182,6 +194,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case sessionLoadedMsg: m.state = uiChat m.session = &msg.sess + // Load the last 20 messages from this session. + msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID) + for _, message := range msgs { + m.chat.AppendMessage(message) + } + m.chat.ScrollToBottom() case sessionFilesLoadedMsg: m.sessionFiles = msg.files case pubsub.Event[history.File]: @@ -200,13 +218,53 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.updateLayoutAndSize() - m.chat.ScrollToBottom() + m.canvas.Resize(msg.Width, msg.Height) case tea.KeyboardEnhancementsMsg: m.keyenh = msg if msg.SupportsKeyDisambiguation() { m.keyMap.Models.SetHelp("ctrl+m", "models") m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline") } + case tea.MouseClickMsg: + switch m.state { + case uiChat: + m.chat.HandleMouseDown(msg.X, msg.Y) + } + + case tea.MouseMotionMsg: + switch m.state { + case uiChat: + if msg.Y <= 0 { + m.chat.ScrollBy(-1) + } else if msg.Y >= m.chat.Height()-1 { + m.chat.ScrollBy(1) + } + m.chat.HandleMouseDrag(msg.X, msg.Y) + } + + case tea.MouseReleaseMsg: + switch m.state { + case uiChat: + m.chat.HandleMouseUp(msg.X, msg.Y) + } + case tea.MouseWheelMsg: + switch m.state { + case uiChat: + switch msg.Button { + case tea.MouseWheelUp: + m.chat.ScrollBy(-5) + if !m.chat.SelectedItemInView() { + m.chat.SelectPrev() + m.chat.ScrollToSelected() + } + case tea.MouseWheelDown: + m.chat.ScrollBy(5) + if !m.chat.SelectedItemInView() { + m.chat.SelectNext() + m.chat.ScrollToSelected() + } + } + } case tea.KeyPressMsg: cmds = append(cmds, m.handleKeyPressMsg(msg)...) } @@ -243,32 +301,18 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { } switch { - case msg.String() == "ctrl+shift+t": - m.chat.SelectPrev() - case msg.String() == "ctrl+t": - m.focus = uiFocusMain - m.state = uiChat - if m.chat.Len() > 0 { - m.chat.AppendItem(list.Gap) - } - m.chat.AppendItem( - list.NewStringItem( - fmt.Sprintf("%d", m.chat.Len()), - fmt.Sprintf("Welcome to Crush Chat! %d", rand.Intn(1000)), - ).WithFocusStyles(&m.com.Styles.BorderFocus, &m.com.Styles.BorderBlur), - ) - m.chat.SetSelectedIndex(m.chat.Len() - 1) - m.chat.Focus() - m.chat.ScrollToBottom() case key.Matches(msg, m.keyMap.Tab): switch m.state { case uiChat: if m.focus == uiFocusMain { m.focus = uiFocusEditor cmds = append(cmds, m.textarea.Focus()) + m.chat.Blur() } else { m.focus = uiFocusMain m.textarea.Blur() + m.chat.Focus() + m.chat.SetSelectedIndex(m.chat.Len() - 1) } } case key.Matches(msg, m.keyMap.Help): @@ -298,65 +342,72 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { // Draw implements [tea.Layer] and draws the UI model. func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { + layout := generateLayout(m, area.Dx(), area.Dy()) + + if m.layout != layout { + m.layout = layout + m.updateSize() + } + // Clear the screen first screen.Clear(scr) switch m.state { case uiConfigure: header := uv.NewStyledString(m.header) - header.Draw(scr, m.layout.header) + header.Draw(scr, layout.header) - mainView := lipgloss.NewStyle().Width(m.layout.main.Dx()). - Height(m.layout.main.Dy()). + mainView := lipgloss.NewStyle().Width(layout.main.Dx()). + Height(layout.main.Dy()). Background(lipgloss.ANSIColor(rand.Intn(256))). Render(" Configure ") main := uv.NewStyledString(mainView) - main.Draw(scr, m.layout.main) + main.Draw(scr, layout.main) case uiInitialize: header := uv.NewStyledString(m.header) - header.Draw(scr, m.layout.header) + header.Draw(scr, layout.header) main := uv.NewStyledString(m.initializeView()) - main.Draw(scr, m.layout.main) + main.Draw(scr, layout.main) case uiLanding: header := uv.NewStyledString(m.header) - header.Draw(scr, m.layout.header) + header.Draw(scr, layout.header) main := uv.NewStyledString(m.landingView()) - main.Draw(scr, m.layout.main) + main.Draw(scr, layout.main) editor := uv.NewStyledString(m.textarea.View()) - editor.Draw(scr, m.layout.editor) + editor.Draw(scr, layout.editor) case uiChat: header := uv.NewStyledString(m.header) - header.Draw(scr, m.layout.header) - m.drawSidebar(scr, m.layout.sidebar) + header.Draw(scr, layout.header) + m.drawSidebar(scr, layout.sidebar) - m.chat.Draw(scr, m.layout.main) + m.chat.Draw(scr, layout.main) editor := uv.NewStyledString(m.textarea.View()) - editor.Draw(scr, m.layout.editor) + editor.Draw(scr, layout.editor) case uiChatCompact: header := uv.NewStyledString(m.header) - header.Draw(scr, m.layout.header) + header.Draw(scr, layout.header) - mainView := lipgloss.NewStyle().Width(m.layout.main.Dx()). - Height(m.layout.main.Dy()). + mainView := lipgloss.NewStyle().Width(layout.main.Dx()). + Height(layout.main.Dy()). Background(lipgloss.ANSIColor(rand.Intn(256))). Render(" Compact Chat Messages ") main := uv.NewStyledString(mainView) - main.Draw(scr, m.layout.main) + main.Draw(scr, layout.main) editor := uv.NewStyledString(m.textarea.View()) - editor.Draw(scr, m.layout.editor) + editor.Draw(scr, layout.editor) } // Add help layer help := uv.NewStyledString(m.help.View(m)) - help.Draw(scr, m.layout.help) + help.Draw(scr, layout.help) // Debugging rendering (visually see when the tui rerenders) if os.Getenv("CRUSH_UI_DEBUG") == "true" { @@ -401,14 +452,11 @@ func (m *UI) View() tea.View { v.AltScreen = true v.BackgroundColor = m.com.Styles.Background v.Cursor = m.Cursor() + v.MouseMode = tea.MouseModeCellMotion - // TODO: Switch to lipgloss.Canvas when available - canvas := uv.NewScreenBuffer(m.width, m.height) - canvas.Method = ansi.GraphemeWidth - - m.Draw(canvas, canvas.Bounds()) + m.Draw(m.canvas, m.canvas.Bounds()) - content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines + content := strings.ReplaceAll(m.canvas.Render(), "\r\n", "\n") // normalize newlines contentLines := strings.Split(content, "\n") for i, line := range contentLines { // Trim trailing spaces for concise rendering @@ -680,6 +728,7 @@ func generateLayout(m *UI, w, h int) layout { // Add padding left sideRect.Min.X += 1 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + mainRect.Max.X -= 1 // Add padding right // Add bottom margin to main mainRect.Max.Y -= 1 layout.sidebar = sideRect diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 049652225920098622e0988c8d073d5e95527d50..59be32af0deabfcfd749f72edc7d4493b8ed8870 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -151,6 +151,20 @@ type Styles struct { Additions lipgloss.Style Deletions lipgloss.Style } + + // Chat + Chat struct { + UserMessageBlurred lipgloss.Style + UserMessageFocused lipgloss.Style + AssistantMessageBlurred lipgloss.Style + AssistantMessageFocused lipgloss.Style + NoContentMessage lipgloss.Style + ThinkingMessage lipgloss.Style + + ErrorTag lipgloss.Style + ErrorTitle lipgloss.Style + ErrorDetails lipgloss.Style + } } func DefaultStyles() Styles { @@ -194,12 +208,14 @@ func DefaultStyles() Styles { greenDark = charmtone.Guac // greenLight = charmtone.Bok - // red = charmtone.Coral + red = charmtone.Coral redDark = charmtone.Sriracha // redLight = charmtone.Salmon // cherry = charmtone.Cherry ) + normalBorder := lipgloss.NormalBorder() + base := lipgloss.NewStyle().Foreground(fgBase) s := Styles{} @@ -607,9 +623,33 @@ func DefaultStyles() Styles { s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted) s.LSP.InfoDiagnostic = s.Base.Foreground(info) + // Files s.Files.Path = s.Muted s.Files.Additions = s.Base.Foreground(greenDark) s.Files.Deletions = s.Base.Foreground(redDark) + + // Chat + messageFocussedBorder := lipgloss.Border{ + Left: "▌", + } + + s.Chat.NoContentMessage = lipgloss.NewStyle().Foreground(fgBase) + s.Chat.UserMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true). + BorderForeground(primary).BorderStyle(normalBorder) + s.Chat.UserMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true). + BorderForeground(primary).BorderStyle(messageFocussedBorder) + s.Chat.AssistantMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(2) + s.Chat.AssistantMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true). + BorderForeground(greenDark).BorderStyle(messageFocussedBorder) + s.Chat.ThinkingMessage = lipgloss.NewStyle().MaxHeight(10) + s.Chat.ErrorTag = lipgloss.NewStyle().Padding(0, 1). + Background(red).Foreground(white) + s.Chat.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted) + s.Chat.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle) + + // Text selection. + s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple) + return s } From ca3e06a43cd8bd111952f174b27e945cc6a9e3d3 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 2 Dec 2025 16:12:24 -0500 Subject: [PATCH 025/335] feat(ui): chat: add navigation and keybindings --- internal/ui/list/list.go | 43 ++++++++++++++++++ internal/ui/model/chat.go | 60 +++++++++++++++++++++++-- internal/ui/model/keys.go | 51 +++++++++++++++++++++ internal/ui/model/ui.go | 93 +++++++++++++++++++++++++++++---------- 4 files changed, 220 insertions(+), 27 deletions(-) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 8928cb0f011fffe2e1f70c3a34b7a6bad6212f67..6fd116c5a106d5e26db58189e91d2e4b60da956d 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -818,6 +818,49 @@ func (l *List) TotalHeight() int { return l.totalHeight } +// SelectFirstInView selects the first item that is fully visible in the viewport. +func (l *List) SelectFirstInView() { + l.ensureBuilt() + + viewportStart := l.offset + viewportEnd := l.offset + l.height + + for i, item := range l.items { + pos, ok := l.itemPositions[item.ID()] + if !ok { + continue + } + + // Check if item is fully within viewport bounds + if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd { + l.SetSelectedIndex(i) + return + } + } +} + +// SelectLastInView selects the last item that is fully visible in the viewport. +func (l *List) SelectLastInView() { + l.ensureBuilt() + + viewportStart := l.offset + viewportEnd := l.offset + l.height + + for i := len(l.items) - 1; i >= 0; i-- { + item := l.items[i] + pos, ok := l.itemPositions[item.ID()] + if !ok { + continue + } + + // Check if item is fully within viewport bounds + if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd { + l.SetSelectedIndex(i) + return + } + } +} + // SelectedItemInView returns true if the selected item is currently visible in the viewport. func (l *List) SelectedItemInView() bool { if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 28272d8b6356ebd7de39d888f6de886b1c2e3b0e..55fd9f58d161f1caa30ecb55b22e142d8e911aae 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -77,9 +77,6 @@ func NewChatNoContentItem(t *styles.Styles, id string) *ChatNoContentItem { // ChatMessageItem represents a chat message item in the chat UI. type ChatMessageItem struct { - list.BaseFocusable - list.BaseHighlightable - item list.Item msg message.Message } @@ -169,6 +166,43 @@ func (c *ChatMessageItem) ID() string { return c.item.ID() } +// Blur implements list.Focusable. +func (c *ChatMessageItem) Blur() { + if blurable, ok := c.item.(list.Focusable); ok { + blurable.Blur() + } +} + +// Focus implements list.Focusable. +func (c *ChatMessageItem) Focus() { + if focusable, ok := c.item.(list.Focusable); ok { + focusable.Focus() + } +} + +// IsFocused implements list.Focusable. +func (c *ChatMessageItem) IsFocused() bool { + if focusable, ok := c.item.(list.Focusable); ok { + return focusable.IsFocused() + } + return false +} + +// GetHighlight implements list.Highlightable. +func (c *ChatMessageItem) GetHighlight() (startLine int, startCol int, endLine int, endCol int) { + if highlightable, ok := c.item.(list.Highlightable); ok { + return highlightable.GetHighlight() + } + return 0, 0, 0, 0 +} + +// SetHighlight implements list.Highlightable. +func (c *ChatMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) { + if highlightable, ok := c.item.(list.Highlightable); ok { + highlightable.SetHighlight(startLine, startCol, endLine, endCol) + } +} + // Chat represents the chat UI model that handles chat interactions and // messages. type Chat struct { @@ -279,6 +313,26 @@ func (m *Chat) SelectNext() { m.list.SelectNext() } +// SelectFirst selects the first message in the chat list. +func (m *Chat) SelectFirst() { + m.list.SelectFirst() +} + +// SelectLast selects the last message in the chat list. +func (m *Chat) SelectLast() { + m.list.SelectLast() +} + +// SelectFirstInView selects the first message currently in view. +func (m *Chat) SelectFirstInView() { + m.list.SelectFirstInView() +} + +// SelectLastInView selects the last message currently in view. +func (m *Chat) SelectLastInView() { + m.list.SelectLastInView() +} + // HandleMouseDown handles mouse down events for the chat component. func (m *Chat) HandleMouseDown(x, y int) { m.list.HandleMouseDown(x, y) diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index 143f1d623828b56baa3fd43b86ca74ea2fbd82b9..d146e53853e7a9d6dd234e8b911a636b16e8a170 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -23,6 +23,16 @@ type KeyMap struct { Cancel key.Binding Tab key.Binding Details key.Binding + Down key.Binding + Up key.Binding + DownOneItem key.Binding + UpOneItem key.Binding + PageDown key.Binding + PageUp key.Binding + HalfPageDown key.Binding + HalfPageUp key.Binding + Home key.Binding + End key.Binding } Initialize struct { @@ -135,6 +145,47 @@ func DefaultKeyMap() KeyMap { key.WithHelp("ctrl+d", "toggle details"), ) + km.Chat.Down = key.NewBinding( + key.WithKeys("down", "ctrl+j", "ctrl+n", "j"), + key.WithHelp("↓", "down"), + ) + km.Chat.Up = key.NewBinding( + key.WithKeys("up", "ctrl+k", "ctrl+p", "k"), + key.WithHelp("↑", "up"), + ) + km.Chat.UpOneItem = key.NewBinding( + key.WithKeys("shift+up", "K"), + key.WithHelp("shift+↑", "up one item"), + ) + km.Chat.DownOneItem = key.NewBinding( + key.WithKeys("shift+down", "J"), + key.WithHelp("shift+↓", "down one item"), + ) + km.Chat.HalfPageDown = key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "half page down"), + ) + km.Chat.PageDown = key.NewBinding( + key.WithKeys("pgdown", " ", "f"), + key.WithHelp("f/pgdn", "page down"), + ) + km.Chat.PageUp = key.NewBinding( + key.WithKeys("pgup", "b"), + key.WithHelp("b/pgup", "page up"), + ) + km.Chat.HalfPageUp = key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "half page up"), + ) + km.Chat.Home = key.NewBinding( + key.WithKeys("g", "home"), + key.WithHelp("g", "home"), + ) + km.Chat.End = key.NewBinding( + key.WithKeys("G", "end"), + key.WithHelp("G", "end"), + ) + km.Initialize.Yes = key.NewBinding( key.WithKeys("y", "Y"), key.WithHelp("y", "yes"), diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index d11f25149c4bd7f97e0c256ee232042aed8a3634..a8a38ade3f2b967ef3acc0f7cee9e644e917ecd2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -7,6 +7,7 @@ import ( "os" "slices" "strings" + "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" @@ -177,7 +178,10 @@ func (m *UI) Init() tea.Cmd { } allSessions, _ := m.com.App.Sessions.List(context.Background()) if len(allSessions) > 0 { - cmds = append(cmds, m.loadSession(allSessions[0].ID)) + cmds = append(cmds, func() tea.Msg { + time.Sleep(2 * time.Second) + return m.loadSession(allSessions[0].ID)() + }) } return tea.Batch(cmds...) } @@ -300,10 +304,30 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { return m.updateDialogs(msg) } - switch { - case key.Matches(msg, m.keyMap.Tab): - switch m.state { - case uiChat: + handleGlobalKeys := func(msg tea.KeyPressMsg) { + switch { + case key.Matches(msg, m.keyMap.Tab): + case key.Matches(msg, m.keyMap.Help): + m.help.ShowAll = !m.help.ShowAll + m.updateLayoutAndSize() + case key.Matches(msg, m.keyMap.Quit): + if !m.dialog.ContainsDialog(dialog.QuitDialogID) { + m.dialog.AddDialog(dialog.NewQuit(m.com)) + return + } + case key.Matches(msg, m.keyMap.Commands): + // TODO: Implement me + case key.Matches(msg, m.keyMap.Models): + // TODO: Implement me + case key.Matches(msg, m.keyMap.Sessions): + // TODO: Implement me + } + } + + switch m.state { + case uiChat: + switch { + case key.Matches(msg, m.keyMap.Tab): if m.focus == uiFocusMain { m.focus = uiFocusEditor cmds = append(cmds, m.textarea.Focus()) @@ -314,26 +338,47 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { m.chat.Focus() m.chat.SetSelectedIndex(m.chat.Len() - 1) } + case key.Matches(msg, m.keyMap.Chat.Up): + m.chat.ScrollBy(-1) + if !m.chat.SelectedItemInView() { + m.chat.SelectPrev() + m.chat.ScrollToSelected() + } + case key.Matches(msg, m.keyMap.Chat.Down): + m.chat.ScrollBy(1) + if !m.chat.SelectedItemInView() { + m.chat.SelectNext() + m.chat.ScrollToSelected() + } + case key.Matches(msg, m.keyMap.Chat.UpOneItem): + m.chat.SelectPrev() + m.chat.ScrollToSelected() + case key.Matches(msg, m.keyMap.Chat.DownOneItem): + m.chat.SelectNext() + m.chat.ScrollToSelected() + case key.Matches(msg, m.keyMap.Chat.HalfPageUp): + m.chat.ScrollBy(-m.chat.Height() / 2) + m.chat.SelectFirstInView() + case key.Matches(msg, m.keyMap.Chat.HalfPageDown): + m.chat.ScrollBy(m.chat.Height() / 2) + m.chat.SelectLastInView() + case key.Matches(msg, m.keyMap.Chat.PageUp): + m.chat.ScrollBy(-m.chat.Height()) + m.chat.SelectFirstInView() + case key.Matches(msg, m.keyMap.Chat.PageDown): + m.chat.ScrollBy(m.chat.Height()) + m.chat.SelectLastInView() + case key.Matches(msg, m.keyMap.Chat.Home): + m.chat.ScrollToTop() + m.chat.SelectFirst() + case key.Matches(msg, m.keyMap.Chat.End): + m.chat.ScrollToBottom() + m.chat.SelectLast() + default: + handleGlobalKeys(msg) } - case key.Matches(msg, m.keyMap.Help): - m.help.ShowAll = !m.help.ShowAll - m.updateLayoutAndSize() - return cmds - case key.Matches(msg, m.keyMap.Quit): - if !m.dialog.ContainsDialog(dialog.QuitDialogID) { - m.dialog.AddDialog(dialog.NewQuit(m.com)) - return - } - return cmds - case key.Matches(msg, m.keyMap.Commands): - // TODO: Implement me - return cmds - case key.Matches(msg, m.keyMap.Models): - // TODO: Implement me - return cmds - case key.Matches(msg, m.keyMap.Sessions): - // TODO: Implement me - return cmds + default: + handleGlobalKeys(msg) } cmds = append(cmds, m.updateFocused(msg)...) From 60882473c933131a4cd78ed2eb3a4a3ff591c430 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 3 Dec 2025 13:21:07 -0500 Subject: [PATCH 026/335] fix(ui): move canvas initialization to View method --- internal/ui/model/ui.go | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a8a38ade3f2b967ef3acc0f7cee9e644e917ecd2..a86642a86359f9fdcec621081162501bbd0588f6 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -27,7 +27,6 @@ import ( "github.com/charmbracelet/crush/internal/version" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/ultraviolet/screen" - "github.com/charmbracelet/x/ansi" ) // uiFocusState represents the current focus state of the UI. @@ -114,9 +113,6 @@ type UI struct { // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string - - // Canvas for rendering - canvas *uv.ScreenBuffer } // New creates a new instance of the [UI] model. @@ -131,10 +127,6 @@ func New(com *common.Common) *UI { ch := NewChat(com) - // TODO: Switch to lipgloss.Canvas when available - canvas := uv.NewScreenBuffer(0, 0) - canvas.Method = ansi.GraphemeWidth - ui := &UI{ com: com, dialog: dialog.NewOverlay(), @@ -144,7 +136,6 @@ func New(com *common.Common) *UI { state: uiConfigure, textarea: ta, chat: ch, - canvas: &canvas, } // set onboarding state defaults @@ -222,7 +213,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.updateLayoutAndSize() - m.canvas.Resize(msg.Width, msg.Height) case tea.KeyboardEnhancementsMsg: m.keyenh = msg if msg.SupportsKeyDisambiguation() { @@ -499,9 +489,10 @@ func (m *UI) View() tea.View { v.Cursor = m.Cursor() v.MouseMode = tea.MouseModeCellMotion - m.Draw(m.canvas, m.canvas.Bounds()) + canvas := uv.NewScreenBuffer(m.width, m.height) + m.Draw(canvas, canvas.Bounds()) - content := strings.ReplaceAll(m.canvas.Render(), "\r\n", "\n") // normalize newlines + content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines contentLines := strings.Split(content, "\n") for i, line := range contentLines { // Trim trailing spaces for concise rendering @@ -509,6 +500,7 @@ func (m *UI) View() tea.View { } content = strings.Join(contentLines, "\n") + v.Content = content if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { // HACK: use a random percentage to prevent ghostty from hiding it @@ -658,27 +650,21 @@ func (m *UI) updateSize() { // Set help width m.help.SetWidth(m.layout.help.Dx()) + m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy()) + m.textarea.SetWidth(m.layout.editor.Dx()) + m.textarea.SetHeight(m.layout.editor.Dy()) + // Handle different app states switch m.state { - case uiConfigure, uiInitialize: - m.renderHeader(false, m.layout.header.Dx()) - - case uiLanding: + case uiConfigure, uiInitialize, uiLanding: m.renderHeader(false, m.layout.header.Dx()) - m.textarea.SetWidth(m.layout.editor.Dx()) - m.textarea.SetHeight(m.layout.editor.Dy()) case uiChat: m.renderSidebarLogo(m.layout.sidebar.Dx()) - m.textarea.SetWidth(m.layout.editor.Dx()) - m.textarea.SetHeight(m.layout.editor.Dy()) - m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy()) case uiChatCompact: // TODO: set the width and heigh of the chat component m.renderHeader(true, m.layout.header.Dx()) - m.textarea.SetWidth(m.layout.editor.Dx()) - m.textarea.SetHeight(m.layout.editor.Dy()) } } From 17de9c79dbd908b2d988a736da74d558ba942c7b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 3 Dec 2025 16:09:01 -0500 Subject: [PATCH 027/335] refactor(list): remove item IDs for simpler API The chat component can wrap the items with an identifiable interface. --- internal/ui/list/example_test.go | 29 ++-- internal/ui/list/item.go | 35 +--- internal/ui/list/item_test.go | 111 ++++++------ internal/ui/list/list.go | 286 ++++++++++++------------------- internal/ui/list/list_test.go | 100 +++++------ internal/ui/model/chat.go | 31 ++-- internal/ui/model/ui.go | 2 +- 7 files changed, 238 insertions(+), 356 deletions(-) diff --git a/internal/ui/list/example_test.go b/internal/ui/list/example_test.go index d88a616ddb7a58d547c55fb72e46f7fab31a9ec8..e656fe059f16d98db84cccb8af328ffdc92864c1 100644 --- a/internal/ui/list/example_test.go +++ b/internal/ui/list/example_test.go @@ -12,15 +12,15 @@ import ( func Example_basic() { // Create some items items := []list.Item{ - list.NewStringItem("1", "First item"), - list.NewStringItem("2", "Second item"), - list.NewStringItem("3", "Third item"), + list.NewStringItem("First item"), + list.NewStringItem("Second item"), + list.NewStringItem("Third item"), } // Create a list with options l := list.New(items...) l.SetSize(80, 10) - l.SetSelectedIndex(0) + l.SetSelected(0) if true { l.Focus() } @@ -109,7 +109,7 @@ func Example_focusable() { // Create list with first item selected and focused l := list.New(items...) l.SetSize(80, 20) - l.SetSelectedIndex(0) + l.SetSelected(0) if true { l.Focus() } @@ -127,8 +127,8 @@ func Example_focusable() { // Example demonstrates dynamic item updates. func Example_dynamicUpdates() { items := []list.Item{ - list.NewStringItem("1", "Item 1"), - list.NewStringItem("2", "Item 2"), + list.NewStringItem("Item 1"), + list.NewStringItem("Item 2"), } l := list.New(items...) @@ -140,13 +140,13 @@ func Example_dynamicUpdates() { l.Draw(&screen, area) // Update an item - l.UpdateItem("2", list.NewStringItem("2", "Updated Item 2")) + l.UpdateItem(2, list.NewStringItem("Updated Item 2")) // Draw again - only changed item is re-rendered l.Draw(&screen, area) // Append a new item - l.AppendItem(list.NewStringItem("3", "New Item 3")) + l.AppendItem(list.NewStringItem("New Item 3")) // Draw again - master buffer grows efficiently l.Draw(&screen, area) @@ -161,7 +161,6 @@ func Example_scrolling() { items := make([]list.Item, 100) for i := range items { items[i] = list.NewStringItem( - fmt.Sprintf("%d", i), fmt.Sprintf("Item %d", i), ) } @@ -169,7 +168,7 @@ func Example_scrolling() { // Create list with small viewport l := list.New(items...) l.SetSize(80, 10) - l.SetSelectedIndex(0) + l.SetSelected(0) // Draw initial view (shows items 0-9) screen := uv.NewScreenBuffer(80, 10) @@ -181,7 +180,7 @@ func Example_scrolling() { l.Draw(&screen, area) // Now shows items 5-14 // Jump to specific item - l.ScrollToItem("50") + l.ScrollToItem(50) l.Draw(&screen, area) // Now shows item 50 and neighbors // Scroll to bottom @@ -258,9 +257,9 @@ func Example_variableHeights() { func Example_markdown() { // Create markdown items items := []list.Item{ - list.NewMarkdownItem("1", "# Welcome\n\nThis is a **markdown** item."), - list.NewMarkdownItem("2", "## Features\n\n- Supports **bold**\n- Supports *italic*\n- Supports `code`"), - list.NewMarkdownItem("3", "### Code Block\n\n```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```"), + list.NewMarkdownItem("# Welcome\n\nThis is a **markdown** item."), + list.NewMarkdownItem("## Features\n\n- Supports **bold**\n- Supports *italic*\n- Supports `code`"), + list.NewMarkdownItem("### Code Block\n\n```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```"), } // Create list diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go index 1b85cff42a18e1f12f801605b301778773510590..51f930d033d61d7f89634222f0f05b3e0041ac17 100644 --- a/internal/ui/list/item.go +++ b/internal/ui/list/item.go @@ -60,9 +60,6 @@ func toUVStyle(lgStyle lipgloss.Style) uv.Style { type Item interface { uv.Drawable - // ID returns unique identifier for this item. - ID() string - // Height returns the item's height in lines for the given width. // This allows items to calculate height based on text wrapping and available space. Height(width int) int @@ -272,7 +269,6 @@ func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height i type StringItem struct { BaseFocusable BaseHighlightable - id string content string // Raw content string (may contain ANSI styles) wrap bool // Whether to wrap text @@ -305,9 +301,8 @@ func LipglossStyleToCellStyler(lgStyle lipgloss.Style) CellStyler { } // NewStringItem creates a new string item with the given ID and content. -func NewStringItem(id, content string) *StringItem { +func NewStringItem(content string) *StringItem { s := &StringItem{ - id: id, content: content, wrap: false, cache: make(map[int]string), @@ -317,9 +312,8 @@ func NewStringItem(id, content string) *StringItem { } // NewWrappingStringItem creates a new string item that wraps text to fit width. -func NewWrappingStringItem(id, content string) *StringItem { +func NewWrappingStringItem(content string) *StringItem { s := &StringItem{ - id: id, content: content, wrap: true, cache: make(map[int]string), @@ -335,11 +329,6 @@ func (s *StringItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *Str return s } -// ID implements Item. -func (s *StringItem) ID() string { - return s.id -} - // Height implements Item. func (s *StringItem) Height(width int) int { // Calculate content width if we have styles @@ -423,7 +412,6 @@ func (s *StringItem) SetHighlight(startLine, startCol, endLine, endCol int) { type MarkdownItem struct { BaseFocusable BaseHighlightable - id string markdown string // Raw markdown content styleConfig *ansi.StyleConfig // Optional style configuration maxWidth int // Maximum wrap width (default 120) @@ -438,9 +426,8 @@ const DefaultMarkdownMaxWidth = 120 // NewMarkdownItem creates a new markdown item with the given ID and markdown content. // If focusStyle and blurStyle are both non-nil, the item will implement Focusable. -func NewMarkdownItem(id, markdown string) *MarkdownItem { +func NewMarkdownItem(markdown string) *MarkdownItem { m := &MarkdownItem{ - id: id, markdown: markdown, maxWidth: DefaultMarkdownMaxWidth, cache: make(map[int]string), @@ -468,11 +455,6 @@ func (m *MarkdownItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *M return m } -// ID implements Item. -func (m *MarkdownItem) ID() string { - return m.id -} - // Height implements Item. func (m *MarkdownItem) Height(width int) int { // Render the markdown to get its height @@ -563,30 +545,23 @@ func (m *MarkdownItem) SetHighlight(startLine, startCol, endLine, endCol int) { } // Gap is a 1-line spacer item used to add gaps between items. -var Gap = NewSpacerItem("spacer-gap", 1) +var Gap = NewSpacerItem(1) // SpacerItem is an empty item that takes up vertical space. // Useful for adding gaps between items in a list. type SpacerItem struct { - id string height int } var _ Item = (*SpacerItem)(nil) // NewSpacerItem creates a new spacer item with the given ID and height in lines. -func NewSpacerItem(id string, height int) *SpacerItem { +func NewSpacerItem(height int) *SpacerItem { return &SpacerItem{ - id: id, height: height, } } -// ID implements Item. -func (s *SpacerItem) ID() string { - return s.id -} - // Height implements Item. func (s *SpacerItem) Height(width int) int { return s.height diff --git a/internal/ui/list/item_test.go b/internal/ui/list/item_test.go index 4ed2529441fcf2954436eacb0699224054ae4ee4..550a7b3a3cbe1bda035e91fca3efd01df87395db 100644 --- a/internal/ui/list/item_test.go +++ b/internal/ui/list/item_test.go @@ -10,9 +10,9 @@ import ( func TestRenderHelper(t *testing.T) { items := []Item{ - NewStringItem("1", "Item 1"), - NewStringItem("2", "Item 2"), - NewStringItem("3", "Item 3"), + NewStringItem("Item 1"), + NewStringItem("Item 2"), + NewStringItem("Item 3"), } l := New(items...) @@ -39,11 +39,11 @@ func TestRenderHelper(t *testing.T) { func TestRenderWithScrolling(t *testing.T) { items := []Item{ - NewStringItem("1", "Item 1"), - NewStringItem("2", "Item 2"), - NewStringItem("3", "Item 3"), - NewStringItem("4", "Item 4"), - NewStringItem("5", "Item 5"), + NewStringItem("Item 1"), + NewStringItem("Item 2"), + NewStringItem("Item 3"), + NewStringItem("Item 4"), + NewStringItem("Item 5"), } l := New(items...) @@ -89,8 +89,8 @@ func TestRenderEmptyList(t *testing.T) { func TestRenderVsDrawConsistency(t *testing.T) { items := []Item{ - NewStringItem("1", "Item 1"), - NewStringItem("2", "Item 2"), + NewStringItem("Item 1"), + NewStringItem("Item 2"), } l := New(items...) @@ -119,7 +119,7 @@ func TestRenderVsDrawConsistency(t *testing.T) { func BenchmarkRender(b *testing.B) { items := make([]Item, 100) for i := range items { - items[i] = NewStringItem(string(rune(i)), "Item content here") + items[i] = NewStringItem("Item content here") } l := New(items...) @@ -135,7 +135,7 @@ func BenchmarkRender(b *testing.B) { func BenchmarkRenderWithScrolling(b *testing.B) { items := make([]Item, 1000) for i := range items { - items[i] = NewStringItem(string(rune(i)), "Item content here") + items[i] = NewStringItem("Item content here") } l := New(items...) @@ -150,7 +150,7 @@ func BenchmarkRenderWithScrolling(b *testing.B) { } func TestStringItemCache(t *testing.T) { - item := NewStringItem("1", "Test content") + item := NewStringItem("Test content") // First draw at width 80 should populate cache screen1 := uv.NewScreenBuffer(80, 5) @@ -188,14 +188,14 @@ func TestStringItemCache(t *testing.T) { func TestWrappingItemHeight(t *testing.T) { // Short text that fits in one line - item1 := NewWrappingStringItem("1", "Short") + item1 := NewWrappingStringItem("Short") if h := item1.Height(80); h != 1 { t.Errorf("expected height 1 for short text, got %d", h) } // Long text that wraps longText := "This is a very long line that will definitely wrap when constrained to a narrow width" - item2 := NewWrappingStringItem("2", longText) + item2 := NewWrappingStringItem(longText) // At width 80, should be fewer lines than width 20 height80 := item2.Height(80) @@ -207,7 +207,7 @@ func TestWrappingItemHeight(t *testing.T) { } // Non-wrapping version should always be 1 line - item3 := NewStringItem("3", longText) + item3 := NewStringItem(longText) if h := item3.Height(20); h != 1 { t.Errorf("expected height 1 for non-wrapping item, got %d", h) } @@ -215,11 +215,7 @@ func TestWrappingItemHeight(t *testing.T) { func TestMarkdownItemBasic(t *testing.T) { markdown := "# Hello\n\nThis is a **test**." - item := NewMarkdownItem("1", markdown) - - if item.ID() != "1" { - t.Errorf("expected ID '1', got '%s'", item.ID()) - } + item := NewMarkdownItem(markdown) // Test that height is calculated height := item.Height(80) @@ -241,7 +237,7 @@ func TestMarkdownItemBasic(t *testing.T) { func TestMarkdownItemCache(t *testing.T) { markdown := "# Test\n\nSome content." - item := NewMarkdownItem("1", markdown) + item := NewMarkdownItem(markdown) // First render at width 80 should populate cache height1 := item.Height(80) @@ -267,7 +263,7 @@ func TestMarkdownItemCache(t *testing.T) { func TestMarkdownItemMaxCacheWidth(t *testing.T) { markdown := "# Test\n\nSome content." - item := NewMarkdownItem("1", markdown).WithMaxWidth(50) + item := NewMarkdownItem(markdown).WithMaxWidth(50) // Render at width 40 (below limit) - should cache at width 40 _ = item.Height(40) @@ -302,7 +298,7 @@ func TestMarkdownItemWithStyleConfig(t *testing.T) { }, } - item := NewMarkdownItem("1", markdown).WithStyleConfig(styleConfig) + item := NewMarkdownItem(markdown).WithStyleConfig(styleConfig) // Render should use the custom style height := item.Height(80) @@ -323,9 +319,9 @@ func TestMarkdownItemWithStyleConfig(t *testing.T) { func TestMarkdownItemInList(t *testing.T) { items := []Item{ - NewMarkdownItem("1", "# First\n\nMarkdown item."), - NewMarkdownItem("2", "# Second\n\nAnother item."), - NewStringItem("3", "Regular string item"), + NewMarkdownItem("# First\n\nMarkdown item."), + NewMarkdownItem("# Second\n\nAnother item."), + NewStringItem("Regular string item"), } l := New(items...) @@ -353,7 +349,7 @@ func TestMarkdownItemHeightWithWidth(t *testing.T) { // Test that widths are capped to maxWidth markdown := "This is a paragraph with some text." - item := NewMarkdownItem("1", markdown).WithMaxWidth(50) + item := NewMarkdownItem(markdown).WithMaxWidth(50) // At width 30 (below limit), should cache and render at width 30 height30 := item.Height(30) @@ -381,7 +377,7 @@ func TestMarkdownItemHeightWithWidth(t *testing.T) { func BenchmarkMarkdownItemRender(b *testing.B) { markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3" - item := NewMarkdownItem("1", markdown) + item := NewMarkdownItem(markdown) // Prime the cache screen := uv.NewScreenBuffer(80, 10) @@ -401,7 +397,7 @@ func BenchmarkMarkdownItemUncached(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - item := NewMarkdownItem("1", markdown) + item := NewMarkdownItem(markdown) screen := uv.NewScreenBuffer(80, 10) area := uv.Rect(0, 0, 80, 10) item.Draw(&screen, area) @@ -409,12 +405,7 @@ func BenchmarkMarkdownItemUncached(b *testing.B) { } func TestSpacerItem(t *testing.T) { - spacer := NewSpacerItem("spacer1", 3) - - // Check ID - if spacer.ID() != "spacer1" { - t.Errorf("expected ID 'spacer1', got %q", spacer.ID()) - } + spacer := NewSpacerItem(3) // Check height if h := spacer.Height(80); h != 3 { @@ -444,11 +435,11 @@ func TestSpacerItem(t *testing.T) { func TestSpacerItemInList(t *testing.T) { // Create a list with items separated by spacers items := []Item{ - NewStringItem("1", "Item 1"), - NewSpacerItem("spacer1", 1), - NewStringItem("2", "Item 2"), - NewSpacerItem("spacer2", 2), - NewStringItem("3", "Item 3"), + NewStringItem("Item 1"), + NewSpacerItem(1), + NewStringItem("Item 2"), + NewSpacerItem(2), + NewStringItem("Item 3"), } l := New(items...) @@ -477,28 +468,28 @@ func TestSpacerItemInList(t *testing.T) { func TestSpacerItemNavigation(t *testing.T) { // Spacers should not be selectable (they're not focusable) items := []Item{ - NewStringItem("1", "Item 1"), - NewSpacerItem("spacer1", 1), - NewStringItem("2", "Item 2"), + NewStringItem("Item 1"), + NewSpacerItem(1), + NewStringItem("Item 2"), } l := New(items...) l.SetSize(20, 10) // Select first item - l.SetSelectedIndex(0) + l.SetSelected(0) if l.SelectedIndex() != 0 { t.Errorf("expected selected index 0, got %d", l.SelectedIndex()) } // Can select the spacer (it's a valid item, just not focusable) - l.SetSelectedIndex(1) + l.SetSelected(1) if l.SelectedIndex() != 1 { t.Errorf("expected selected index 1, got %d", l.SelectedIndex()) } // Can select item after spacer - l.SetSelectedIndex(2) + l.SetSelected(2) if l.SelectedIndex() != 2 { t.Errorf("expected selected index 2, got %d", l.SelectedIndex()) } @@ -512,11 +503,11 @@ func uintPtr(v uint) *uint { func TestListDoesNotEatLastLine(t *testing.T) { // Create items that exactly fill the viewport items := []Item{ - NewStringItem("1", "Line 1"), - NewStringItem("2", "Line 2"), - NewStringItem("3", "Line 3"), - NewStringItem("4", "Line 4"), - NewStringItem("5", "Line 5"), + NewStringItem("Line 1"), + NewStringItem("Line 2"), + NewStringItem("Line 3"), + NewStringItem("Line 4"), + NewStringItem("Line 5"), } // Create list with height exactly matching content (5 lines, no gaps) @@ -527,7 +518,7 @@ func TestListDoesNotEatLastLine(t *testing.T) { output := l.Render() // Count actual lines in output - lines := strings.Split(strings.TrimRight(output, "\r\n"), "\r\n") + lines := strings.Split(strings.TrimRight(output, "\n"), "\n") actualLineCount := 0 for _, line := range lines { if strings.TrimSpace(line) != "" { @@ -560,13 +551,13 @@ func TestListDoesNotEatLastLine(t *testing.T) { func TestListWithScrollDoesNotEatLastLine(t *testing.T) { // Create more items than viewport height items := []Item{ - NewStringItem("1", "Item 1"), - NewStringItem("2", "Item 2"), - NewStringItem("3", "Item 3"), - NewStringItem("4", "Item 4"), - NewStringItem("5", "Item 5"), - NewStringItem("6", "Item 6"), - NewStringItem("7", "Item 7"), + NewStringItem("Item 1"), + NewStringItem("Item 2"), + NewStringItem("Item 3"), + NewStringItem("Item 4"), + NewStringItem("Item 5"), + NewStringItem("Item 6"), + NewStringItem("Item 7"), } // Viewport shows 3 items at a time diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 6fd116c5a106d5e26db58189e91d2e4b60da956d..7cde0f879ae3705421d5a6a27fd04c87a59ac0bc 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -16,8 +16,7 @@ type List struct { width, height int // Data - items []Item - indexMap map[string]int // ID -> index for fast lookup + items []Item // Focus & Selection focused bool @@ -28,23 +27,23 @@ type List struct { totalHeight int // Item positioning in master buffer - itemPositions map[string]itemPosition + itemPositions []itemPosition // Viewport state offset int // Scroll offset in lines from top // Mouse state mouseDown bool - mouseDownItem string // Item ID where mouse was pressed - mouseDownX int // X position in item content (character offset) - mouseDownY int // Y position in item (line offset) - mouseDragItem string // Current item being dragged over - mouseDragX int // Current X in item content - mouseDragY int // Current Y in item + mouseDownItem int // Item index where mouse was pressed + mouseDownX int // X position in item content (character offset) + mouseDownY int // Y position in item (line offset) + mouseDragItem int // Current item index being dragged over + mouseDragX int // Current X in item content + mouseDragY int // Current Y in item // Dirty tracking dirty bool - dirtyItems map[string]bool + dirtyItems map[int]bool } type itemPosition struct { @@ -56,15 +55,11 @@ type itemPosition struct { func New(items ...Item) *List { l := &List{ items: items, - indexMap: make(map[string]int, len(items)), - itemPositions: make(map[string]itemPosition, len(items)), - dirtyItems: make(map[string]bool), + itemPositions: make([]itemPosition, len(items)), + dirtyItems: make(map[int]bool), selectedIdx: -1, - } - - // Build index map - for i, item := range items { - l.indexMap[item.ID()] = i + mouseDownItem: -1, + mouseDragItem: -1, } l.dirty = true @@ -251,7 +246,7 @@ func (l *List) rebuildMasterBuffer() { // Draw each item currentY := 0 - for _, item := range l.items { + for i, item := range l.items { itemHeight := item.Height(l.width) // Draw item to master buffer @@ -259,7 +254,7 @@ func (l *List) rebuildMasterBuffer() { item.Draw(l.masterBuffer, area) // Store position - l.itemPositions[item.ID()] = itemPosition{ + l.itemPositions[i] = itemPosition{ startLine: currentY, height: itemHeight, } @@ -269,7 +264,7 @@ func (l *List) rebuildMasterBuffer() { } l.dirty = false - l.dirtyItems = make(map[string]bool) + l.dirtyItems = make(map[int]bool) } // updateDirtyItems efficiently updates only changed items using slice operations. @@ -280,21 +275,9 @@ func (l *List) updateDirtyItems() { // Check if all dirty items have unchanged heights allSameHeight := true - for id := range l.dirtyItems { - idx, ok := l.indexMap[id] - if !ok { - continue - } - + for idx := range l.dirtyItems { item := l.items[idx] - pos, ok := l.itemPositions[id] - if !ok { - l.dirty = true - l.dirtyItems = make(map[string]bool) - l.rebuildMasterBuffer() - return - } - + pos := l.itemPositions[idx] newHeight := item.Height(l.width) if newHeight != pos.height { allSameHeight = false @@ -305,10 +288,9 @@ func (l *List) updateDirtyItems() { // Optimization: If all dirty items have unchanged heights, re-render in place if allSameHeight { buf := l.masterBuffer.Buffer - for id := range l.dirtyItems { - idx := l.indexMap[id] + for idx := range l.dirtyItems { item := l.items[idx] - pos := l.itemPositions[id] + pos := l.itemPositions[idx] // Clear the item's area for y := pos.startLine; y < pos.startLine+pos.height && y < len(buf.Lines); y++ { @@ -320,23 +302,22 @@ func (l *List) updateDirtyItems() { item.Draw(l.masterBuffer, area) } - l.dirtyItems = make(map[string]bool) + l.dirtyItems = make(map[int]bool) return } // Height changed - full rebuild l.dirty = true - l.dirtyItems = make(map[string]bool) + l.dirtyItems = make(map[int]bool) l.rebuildMasterBuffer() } // updatePositionsBelow updates the startLine for all items below the given index. func (l *List) updatePositionsBelow(fromIdx int, delta int) { for i := fromIdx + 1; i < len(l.items); i++ { - item := l.items[i] - pos := l.itemPositions[item.ID()] + pos := l.itemPositions[i] pos.startLine += delta - l.itemPositions[item.ID()] = pos + l.itemPositions[i] = pos } } @@ -395,13 +376,7 @@ func (l *List) Len() int { // SetItems replaces all items in the list. func (l *List) SetItems(items []Item) { l.items = items - l.indexMap = make(map[string]int, len(items)) - l.itemPositions = make(map[string]itemPosition, len(items)) - - for i, item := range items { - l.indexMap[item.ID()] = i - } - + l.itemPositions = make([]itemPosition, len(items)) l.dirty = true } @@ -410,15 +385,15 @@ func (l *List) Items() []Item { return l.items } -// AppendItem adds an item to the end of the list. -func (l *List) AppendItem(item Item) { +// AppendItem adds an item to the end of the list. Returns true if successful. +func (l *List) AppendItem(item Item) bool { l.items = append(l.items, item) - l.indexMap[item.ID()] = len(l.items) - 1 + l.itemPositions = append(l.itemPositions, itemPosition{}) // If buffer not built yet, mark dirty for full rebuild if l.masterBuffer == nil || l.width <= 0 { l.dirty = true - return + return true } // Process any pending dirty items before modifying buffer structure @@ -442,23 +417,20 @@ func (l *List) AppendItem(item Item) { item.Draw(l.masterBuffer, area) // Update tracking - l.itemPositions[item.ID()] = itemPosition{ + l.itemPositions[len(l.items)-1] = itemPosition{ startLine: startLine, height: itemHeight, } l.totalHeight += itemHeight + + return true } -// PrependItem adds an item to the beginning of the list. -func (l *List) PrependItem(item Item) { +// PrependItem adds an item to the beginning of the list. Returns true if +// successful. +func (l *List) PrependItem(item Item) bool { l.items = append([]Item{item}, l.items...) - - // Rebuild index map (all indices shifted) - l.indexMap = make(map[string]int, len(l.items)) - for i, itm := range l.items { - l.indexMap[itm.ID()] = i - } - + l.itemPositions = append([]itemPosition{{}}, l.itemPositions...) if l.selectedIdx >= 0 { l.selectedIdx++ } @@ -466,7 +438,7 @@ func (l *List) PrependItem(item Item) { // If buffer not built yet, mark dirty for full rebuild if l.masterBuffer == nil || l.width <= 0 { l.dirty = true - return + return true } // Process any pending dirty items before modifying buffer structure @@ -492,42 +464,41 @@ func (l *List) PrependItem(item Item) { item.Draw(l.masterBuffer, area) // Update all positions (shift everything down) - for i := 1; i < len(l.items); i++ { - itm := l.items[i] - if pos, ok := l.itemPositions[itm.ID()]; ok { - pos.startLine += itemHeight - l.itemPositions[itm.ID()] = pos - } + for i := range l.itemPositions { + pos := l.itemPositions[i] + pos.startLine += itemHeight + l.itemPositions[i] = pos } - // Add position for new item - l.itemPositions[item.ID()] = itemPosition{ + // Add position for new item at start + l.itemPositions[0] = itemPosition{ startLine: 0, height: itemHeight, } + l.totalHeight += itemHeight + + return true } -// UpdateItem replaces an item with the same ID. -func (l *List) UpdateItem(id string, item Item) { - idx, ok := l.indexMap[id] - if !ok { - return +// UpdateItem replaces an item with the same index. Returns true if successful. +func (l *List) UpdateItem(idx int, item Item) bool { + if idx < 0 || idx >= len(l.items) { + return false } - l.items[idx] = item - l.dirtyItems[id] = true + l.dirtyItems[idx] = true + return true } -// DeleteItem removes an item by ID. -func (l *List) DeleteItem(id string) { - idx, ok := l.indexMap[id] - if !ok { - return +// DeleteItem removes an item by index. Returns true if successful. +func (l *List) DeleteItem(idx int) bool { + if idx < 0 || idx >= len(l.items) { + return false } // Get position before deleting - pos, hasPos := l.itemPositions[id] + pos := l.itemPositions[idx] // Process any pending dirty items before modifying buffer structure if len(l.dirtyItems) > 0 { @@ -535,13 +506,7 @@ func (l *List) DeleteItem(id string) { } l.items = append(l.items[:idx], l.items[idx+1:]...) - delete(l.indexMap, id) - delete(l.itemPositions, id) - - // Rebuild index map for items after deleted one - for i := idx; i < len(l.items); i++ { - l.indexMap[l.items[i].ID()] = i - } + l.itemPositions = append(l.itemPositions[:idx], l.itemPositions[idx+1:]...) // Adjust selection if l.selectedIdx == idx { @@ -557,9 +522,9 @@ func (l *List) DeleteItem(id string) { } // If buffer not built yet, mark dirty for full rebuild - if l.masterBuffer == nil || !hasPos { + if l.masterBuffer == nil { l.dirty = true - return + return true } // Efficient delete: remove lines from buffer @@ -575,6 +540,8 @@ func (l *List) DeleteItem(id string) { // Position data corrupt, rebuild l.dirty = true } + + return true } // Focus focuses the list and the selected item (if focusable). @@ -595,12 +562,10 @@ func (l *List) Focused() bool { } // SetSelected sets the selected item by ID. -func (l *List) SetSelected(id string) { - idx, ok := l.indexMap[id] - if !ok { +func (l *List) SetSelected(idx int) { + if idx < 0 || idx >= len(l.items) { return } - if l.selectedIdx == idx { return } @@ -613,33 +578,25 @@ func (l *List) SetSelected(id string) { if prevIdx >= 0 && prevIdx < len(l.items) { if f, ok := l.items[prevIdx].(Focusable); ok { f.Blur() - l.dirtyItems[l.items[prevIdx].ID()] = true + l.dirtyItems[prevIdx] = true } } if f, ok := l.items[idx].(Focusable); ok { f.Focus() - l.dirtyItems[l.items[idx].ID()] = true + l.dirtyItems[idx] = true } } } -// SetSelectedIndex sets the selected item by index. -func (l *List) SetSelectedIndex(idx int) { - if idx < 0 || idx >= len(l.items) { - return - } - l.SetSelected(l.items[idx].ID()) -} - // SelectFirst selects the first item in the list. func (l *List) SelectFirst() { - l.SetSelectedIndex(0) + l.SetSelected(0) } // SelectLast selects the last item in the list. func (l *List) SelectLast() { - l.SetSelectedIndex(len(l.items) - 1) + l.SetSelected(len(l.items) - 1) } // SelectNextWrap selects the next item in the list (wraps to beginning). @@ -679,7 +636,7 @@ func (l *List) selectNext(wrap bool) { } // Select and scroll to this item - l.SetSelected(l.items[nextIdx].ID()) + l.SetSelected(nextIdx) return } } @@ -721,7 +678,7 @@ func (l *List) selectPrev(wrap bool) { } // Select and scroll to this item - l.SetSelected(l.items[prevIdx].ID()) + l.SetSelected(prevIdx) return } } @@ -773,13 +730,9 @@ func (l *List) ScrollToBottom() { } // ScrollToItem scrolls to make the item with the given ID visible. -func (l *List) ScrollToItem(id string) { +func (l *List) ScrollToItem(idx int) { l.ensureBuilt() - pos, ok := l.itemPositions[id] - if !ok { - return - } - + pos := l.itemPositions[idx] itemStart := pos.startLine itemEnd := pos.startLine + pos.height viewStart := l.offset @@ -805,7 +758,7 @@ func (l *List) ScrollToSelected() { if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { return } - l.ScrollToItem(l.items[l.selectedIdx].ID()) + l.ScrollToItem(l.selectedIdx) } // Offset returns the current scroll offset. @@ -825,15 +778,12 @@ func (l *List) SelectFirstInView() { viewportStart := l.offset viewportEnd := l.offset + l.height - for i, item := range l.items { - pos, ok := l.itemPositions[item.ID()] - if !ok { - continue - } + for i := range l.items { + pos := l.itemPositions[i] // Check if item is fully within viewport bounds if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd { - l.SetSelectedIndex(i) + l.SetSelected(i) return } } @@ -847,15 +797,11 @@ func (l *List) SelectLastInView() { viewportEnd := l.offset + l.height for i := len(l.items) - 1; i >= 0; i-- { - item := l.items[i] - pos, ok := l.itemPositions[item.ID()] - if !ok { - continue - } + pos := l.itemPositions[i] // Check if item is fully within viewport bounds if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd { - l.SetSelectedIndex(i) + l.SetSelected(i) return } } @@ -868,11 +814,7 @@ func (l *List) SelectedItemInView() bool { } // Get selected item ID and position - item := l.items[l.selectedIdx] - pos, ok := l.itemPositions[item.ID()] - if !ok { - return false - } + pos := l.itemPositions[l.selectedIdx] // Check if item is within viewport bounds viewportStart := l.offset @@ -896,7 +838,7 @@ func (l *List) focusSelectedItem() { item := l.items[l.selectedIdx] if f, ok := item.(Focusable); ok { f.Focus() - l.dirtyItems[item.ID()] = true + l.dirtyItems[l.selectedIdx] = true } } @@ -909,7 +851,7 @@ func (l *List) blurSelectedItem() { item := l.items[l.selectedIdx] if f, ok := item.(Focusable); ok { f.Blur() - l.dirtyItems[item.ID()] = true + l.dirtyItems[l.selectedIdx] = true } } @@ -923,8 +865,8 @@ func (l *List) HandleMouseDown(x, y int) bool { bufferY := y + l.offset // Find which item was clicked - itemID, itemY := l.findItemAtPosition(bufferY) - if itemID == "" { + itemIdx, itemY := l.findItemAtPosition(bufferY) + if itemIdx < 0 { return false } @@ -933,17 +875,15 @@ func (l *List) HandleMouseDown(x, y int) bool { // Items can interpret this as character offset in their content l.mouseDown = true - l.mouseDownItem = itemID + l.mouseDownItem = itemIdx l.mouseDownX = x l.mouseDownY = itemY - l.mouseDragItem = itemID + l.mouseDragItem = itemIdx l.mouseDragX = x l.mouseDragY = itemY // Select the clicked item - if idx, ok := l.indexMap[itemID]; ok { - l.SetSelectedIndex(idx) - } + l.SetSelected(itemIdx) return true } @@ -962,12 +902,12 @@ func (l *List) HandleMouseDrag(x, y int) bool { bufferY := y + l.offset // Find which item we're dragging over - itemID, itemY := l.findItemAtPosition(bufferY) - if itemID == "" { + itemIdx, itemY := l.findItemAtPosition(bufferY) + if itemIdx < 0 { return false } - l.mouseDragItem = itemID + l.mouseDragItem = itemIdx l.mouseDragX = x l.mouseDragY = itemY @@ -994,47 +934,46 @@ func (l *List) HandleMouseUp(x, y int) bool { // ClearHighlight clears any active text highlighting. func (l *List) ClearHighlight() { - for _, item := range l.items { + for i, item := range l.items { if h, ok := item.(Highlightable); ok { h.SetHighlight(-1, -1, -1, -1) - l.dirtyItems[item.ID()] = true + l.dirtyItems[i] = true } } + l.mouseDownItem = -1 + l.mouseDragItem = -1 } // findItemAtPosition finds the item at the given master buffer y coordinate. -// Returns the item ID and the y offset within that item. -func (l *List) findItemAtPosition(bufferY int) (itemID string, itemY int) { +// Returns the item index and the y offset within that item. It returns -1, -1 +// if no item is found. +func (l *List) findItemAtPosition(bufferY int) (itemIdx int, itemY int) { if bufferY < 0 || bufferY >= l.totalHeight { - return "", 0 + return -1, -1 } // Linear search through items to find which one contains this y // This could be optimized with binary search if needed - for _, item := range l.items { - pos, ok := l.itemPositions[item.ID()] - if !ok { - continue - } - + for i := range l.items { + pos := l.itemPositions[i] if bufferY >= pos.startLine && bufferY < pos.startLine+pos.height { - return item.ID(), bufferY - pos.startLine + return i, bufferY - pos.startLine } } - return "", 0 + return -1, -1 } // updateHighlight updates the highlight range for highlightable items. // Supports highlighting across multiple items and respects drag direction. func (l *List) updateHighlight() { - if l.mouseDownItem == "" { + if l.mouseDownItem < 0 { return } // Get start and end item indices - downItemIdx := l.indexMap[l.mouseDownItem] - dragItemIdx := l.indexMap[l.mouseDragItem] + downItemIdx := l.mouseDownItem + dragItemIdx := l.mouseDragItem // Determine selection direction draggingDown := dragItemIdx > downItemIdx || @@ -1064,10 +1003,10 @@ func (l *List) updateHighlight() { } // Clear all highlights first - for _, item := range l.items { + for i, item := range l.items { if h, ok := item.(Highlightable); ok { h.SetHighlight(-1, -1, -1, -1) - l.dirtyItems[item.ID()] = true + l.dirtyItems[i] = true } } @@ -1083,18 +1022,18 @@ func (l *List) updateHighlight() { item.SetHighlight(startLine, startCol, endLine, endCol) } else if idx == startItemIdx { // First item - from start position to end of item - pos := l.itemPositions[l.items[idx].ID()] + pos := l.itemPositions[idx] item.SetHighlight(startLine, startCol, pos.height-1, 9999) // 9999 = end of line } else if idx == endItemIdx { // Last item - from start of item to end position item.SetHighlight(0, 0, endLine, endCol) } else { // Middle item - fully highlighted - pos := l.itemPositions[l.items[idx].ID()] + pos := l.itemPositions[idx] item.SetHighlight(0, 0, pos.height-1, 9999) } - l.dirtyItems[l.items[idx].ID()] = true + l.dirtyItems[idx] = true } } @@ -1110,7 +1049,7 @@ func (l *List) GetHighlightedText() string { var result strings.Builder // Iterate through items to find highlighted ones - for _, item := range l.items { + for i, item := range l.items { h, ok := item.(Highlightable) if !ok { continue @@ -1121,10 +1060,7 @@ func (l *List) GetHighlightedText() string { continue } - pos, ok := l.itemPositions[item.ID()] - if !ok { - continue - } + pos := l.itemPositions[i] // Extract text from highlighted region in master buffer for y := startLine; y <= endLine && y < pos.height; y++ { diff --git a/internal/ui/list/list_test.go b/internal/ui/list/list_test.go index e4f6dcff6714af0e55dc7397809235f70b386766..ac2a64e5e06879340ec8da93d273dfd53882e60e 100644 --- a/internal/ui/list/list_test.go +++ b/internal/ui/list/list_test.go @@ -11,9 +11,9 @@ import ( func TestNewList(t *testing.T) { items := []Item{ - NewStringItem("1", "Item 1"), - NewStringItem("2", "Item 2"), - NewStringItem("3", "Item 3"), + NewStringItem("Item 1"), + NewStringItem("Item 2"), + NewStringItem("Item 3"), } l := New(items...) @@ -30,9 +30,9 @@ func TestNewList(t *testing.T) { func TestListDraw(t *testing.T) { items := []Item{ - NewStringItem("1", "Item 1"), - NewStringItem("2", "Item 2"), - NewStringItem("3", "Item 3"), + NewStringItem("Item 1"), + NewStringItem("Item 2"), + NewStringItem("Item 3"), } l := New(items...) @@ -54,52 +54,44 @@ func TestListDraw(t *testing.T) { func TestListAppendItem(t *testing.T) { items := []Item{ - NewStringItem("1", "Item 1"), + NewStringItem("Item 1"), } l := New(items...) - l.AppendItem(NewStringItem("2", "Item 2")) + l.AppendItem(NewStringItem("Item 2")) if len(l.items) != 2 { t.Errorf("expected 2 items after append, got %d", len(l.items)) } - - if l.items[1].ID() != "2" { - t.Errorf("expected item ID '2', got '%s'", l.items[1].ID()) - } } func TestListDeleteItem(t *testing.T) { items := []Item{ - NewStringItem("1", "Item 1"), - NewStringItem("2", "Item 2"), - NewStringItem("3", "Item 3"), + NewStringItem("Item 1"), + NewStringItem("Item 2"), + NewStringItem("Item 3"), } l := New(items...) - l.DeleteItem("2") + l.DeleteItem(2) if len(l.items) != 2 { t.Errorf("expected 2 items after delete, got %d", len(l.items)) } - - if l.items[1].ID() != "3" { - t.Errorf("expected item ID '3', got '%s'", l.items[1].ID()) - } } func TestListUpdateItem(t *testing.T) { items := []Item{ - NewStringItem("1", "Item 1"), - NewStringItem("2", "Item 2"), + NewStringItem("Item 1"), + NewStringItem("Item 2"), } l := New(items...) l.SetSize(80, 10) // Update item - newItem := NewStringItem("2", "Updated Item 2") - l.UpdateItem("2", newItem) + newItem := NewStringItem("Updated Item 2") + l.UpdateItem(1, newItem) if l.items[1].(*StringItem).content != "Updated Item 2" { t.Errorf("expected updated content, got '%s'", l.items[1].(*StringItem).content) @@ -108,13 +100,13 @@ func TestListUpdateItem(t *testing.T) { func TestListSelection(t *testing.T) { items := []Item{ - NewStringItem("1", "Item 1"), - NewStringItem("2", "Item 2"), - NewStringItem("3", "Item 3"), + NewStringItem("Item 1"), + NewStringItem("Item 2"), + NewStringItem("Item 3"), } l := New(items...) - l.SetSelectedIndex(0) + l.SetSelected(0) if l.SelectedIndex() != 0 { t.Errorf("expected selected index 0, got %d", l.SelectedIndex()) @@ -133,11 +125,11 @@ func TestListSelection(t *testing.T) { func TestListScrolling(t *testing.T) { items := []Item{ - NewStringItem("1", "Item 1"), - NewStringItem("2", "Item 2"), - NewStringItem("3", "Item 3"), - NewStringItem("4", "Item 4"), - NewStringItem("5", "Item 5"), + NewStringItem("Item 1"), + NewStringItem("Item 2"), + NewStringItem("Item 3"), + NewStringItem("Item 4"), + NewStringItem("Item 5"), } l := New(items...) @@ -208,7 +200,7 @@ func TestListFocus(t *testing.T) { l := New(items...) l.SetSize(80, 10) - l.SetSelectedIndex(0) + l.SetSelected(0) // Focus the list l.Focus() @@ -256,12 +248,12 @@ func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) { // Start with one item items := []Item{ - NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle), + NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle), } l := New(items...) l.SetSize(20, 15) // 15 lines viewport height - l.SetSelectedIndex(0) + l.SetSelected(0) l.Focus() // Initial draw to build buffer @@ -271,12 +263,12 @@ func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) { // Append items until we exceed viewport height // Each focusable item with border is 5 lines tall for i := 2; i <= 4; i++ { - item := NewStringItem(string(rune('0'+i)), "Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle) + item := NewStringItem("Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle) l.AppendItem(item) } // Select the last item - l.SetSelectedIndex(3) + l.SetSelected(3) // Draw screen = uv.NewScreenBuffer(20, 15) @@ -318,7 +310,7 @@ func TestFocusableItemUpdate(t *testing.T) { BorderForeground(lipgloss.Color("240")) // Create a focusable item - item := NewStringItem("1", "Test Item").WithFocusStyles(&focusStyle, &blurStyle) + item := NewStringItem("Test Item").WithFocusStyles(&focusStyle, &blurStyle) // Initially not focused - render with blur style screen1 := uv.NewScreenBuffer(20, 5) @@ -369,14 +361,14 @@ func TestFocusableItemHeightWithBorder(t *testing.T) { Border(lipgloss.RoundedBorder()) // Item without styles has height 1 - plainItem := NewStringItem("1", "Test") + plainItem := NewStringItem("Test") plainHeight := plainItem.Height(20) if plainHeight != 1 { t.Errorf("expected plain height 1, got %d", plainHeight) } // Item with border should add border height (2 lines) - item := NewStringItem("2", "Test").WithFocusStyles(&borderStyle, &borderStyle) + item := NewStringItem("Test").WithFocusStyles(&borderStyle, &borderStyle) itemHeight := item.Height(20) expectedHeight := 1 + 2 // content + border if itemHeight != expectedHeight { @@ -396,14 +388,14 @@ func TestFocusableItemInList(t *testing.T) { // Create list with focusable items items := []Item{ - NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle), - NewStringItem("2", "Item 2").WithFocusStyles(&focusStyle, &blurStyle), - NewStringItem("3", "Item 3").WithFocusStyles(&focusStyle, &blurStyle), + NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle), + NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle), + NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle), } l := New(items...) l.SetSize(80, 20) - l.SetSelectedIndex(0) + l.SetSelected(0) // Focus the list l.Focus() @@ -421,7 +413,7 @@ func TestFocusableItemInList(t *testing.T) { } // Select second item - l.SetSelectedIndex(1) + l.SetSelected(1) // First item should be blurred, second focused if firstItem.IsFocused() { @@ -447,7 +439,7 @@ func TestFocusableItemInList(t *testing.T) { func TestFocusableItemWithNilStyles(t *testing.T) { // Test with nil styles - should render inner item directly - item := NewStringItem("1", "Plain Item").WithFocusStyles(nil, nil) + item := NewStringItem("Plain Item").WithFocusStyles(nil, nil) // Height should be based on content (no border since styles are nil) itemHeight := item.Height(20) @@ -488,7 +480,7 @@ func TestFocusableItemWithOnlyFocusStyle(t *testing.T) { Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("86")) - item := NewStringItem("1", "Test").WithFocusStyles(&focusStyle, nil) + item := NewStringItem("Test").WithFocusStyles(&focusStyle, nil) // When not focused, should use nil blur style (no border) screen1 := uv.NewScreenBuffer(20, 5) @@ -519,15 +511,15 @@ func TestFocusableItemLastLineNotEaten(t *testing.T) { BorderForeground(lipgloss.Color("240")) items := []Item{ - NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle), + NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle), Gap, - NewStringItem("2", "Item 2").WithFocusStyles(&focusStyle, &blurStyle), + NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle), Gap, - NewStringItem("3", "Item 3").WithFocusStyles(&focusStyle, &blurStyle), + NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle), Gap, - NewStringItem("4", "Item 4").WithFocusStyles(&focusStyle, &blurStyle), + NewStringItem("Item 4").WithFocusStyles(&focusStyle, &blurStyle), Gap, - NewStringItem("5", "Item 5").WithFocusStyles(&focusStyle, &blurStyle), + NewStringItem("Item 5").WithFocusStyles(&focusStyle, &blurStyle), } // Items with padding(1) and border are 5 lines each @@ -543,7 +535,7 @@ func TestFocusableItemLastLineNotEaten(t *testing.T) { l.Focus() // Select last item - l.SetSelectedIndex(len(items) - 1) + l.SetSelected(len(items) - 1) // Scroll to bottom l.ScrollToBottom() diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 55fd9f58d161f1caa30ecb55b22e142d8e911aae..bc38a250876eab39eba7f1ffdba124741ee2ed5d 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -11,7 +11,6 @@ import ( "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" uv "github.com/charmbracelet/ultraviolet" - "github.com/google/uuid" ) // ChatAnimItem represents a chat animation item in the chat UI. @@ -57,20 +56,15 @@ func (c *ChatAnimItem) Height(int) int { return 1 } -// ID implements list.Item. -func (c *ChatAnimItem) ID() string { - return "anim" -} - // ChatNoContentItem represents a chat item with no content. type ChatNoContentItem struct { *list.StringItem } // NewChatNoContentItem creates a new instance of [ChatNoContentItem]. -func NewChatNoContentItem(t *styles.Styles, id string) *ChatNoContentItem { +func NewChatNoContentItem(t *styles.Styles) *ChatNoContentItem { c := new(ChatNoContentItem) - c.StringItem = list.NewStringItem(id, "No message content"). + c.StringItem = list.NewStringItem("No message content"). WithFocusStyles(&t.Chat.NoContentMessage, &t.Chat.NoContentMessage) return c } @@ -93,7 +87,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem switch msg.Role { case message.User: - item := list.NewMarkdownItem(msg.ID, msg.Content().String()). + item := list.NewMarkdownItem(msg.Content().String()). WithFocusStyles(&t.Chat.UserMessageFocused, &t.Chat.UserMessageBlurred) item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection)) // TODO: Add attachments @@ -113,7 +107,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem details := t.Chat.ErrorDetails.Render(finishedData.Details) errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details) - item := list.NewStringItem(msg.ID, errContent). + item := list.NewStringItem(errContent). WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred) c.item = item @@ -141,7 +135,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem parts = append(parts, content) } - item := list.NewMarkdownItem(msg.ID, strings.Join(parts, "\n")). + item := list.NewMarkdownItem(strings.Join(parts, "\n")). WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred) item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection)) @@ -161,11 +155,6 @@ func (c *ChatMessageItem) Height(width int) int { return c.item.Height(width) } -// ID implements list.Item. -func (c *ChatMessageItem) ID() string { - return c.item.ID() -} - // Blur implements list.Focusable. func (c *ChatMessageItem) Blur() { if blurable, ok := c.item.(list.Focusable); ok { @@ -248,7 +237,7 @@ func (m *Chat) PrependItem(item list.Item) { // AppendMessage appends a new message item to the chat list. func (m *Chat) AppendMessage(msg message.Message) { if msg.ID == "" { - m.AppendItem(NewChatNoContentItem(m.com.Styles, uuid.NewString())) + m.AppendItem(NewChatNoContentItem(m.com.Styles)) } else { m.AppendItem(NewChatMessageItem(m.com.Styles, msg)) } @@ -258,7 +247,7 @@ func (m *Chat) AppendMessage(msg message.Message) { func (m *Chat) AppendItem(item list.Item) { if m.Len() > 0 { // Always add a spacer between messages - m.list.AppendItem(list.NewSpacerItem(uuid.NewString(), 1)) + m.list.AppendItem(list.NewSpacerItem(1)) } m.list.AppendItem(item) } @@ -298,9 +287,9 @@ func (m *Chat) SelectedItemInView() bool { return m.list.SelectedItemInView() } -// SetSelectedIndex sets the selected message index in the chat list. -func (m *Chat) SetSelectedIndex(index int) { - m.list.SetSelectedIndex(index) +// SetSelected sets the selected message index in the chat list. +func (m *Chat) SetSelected(index int) { + m.list.SetSelected(index) } // SelectPrev selects the previous message in the chat list. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a86642a86359f9fdcec621081162501bbd0588f6..50521afc211d17435ae03370e2c3008e8f74b196 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -326,7 +326,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { m.focus = uiFocusMain m.textarea.Blur() m.chat.Focus() - m.chat.SetSelectedIndex(m.chat.Len() - 1) + m.chat.SetSelected(m.chat.Len() - 1) } case key.Matches(msg, m.keyMap.Chat.Up): m.chat.ScrollBy(-1) From a1648a8c5f2599c240dfa8b8f5a943032266cb30 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 3 Dec 2025 16:14:40 -0500 Subject: [PATCH 028/335] fix(ui): list: scroll to bottom after session load --- internal/ui/model/ui.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 50521afc211d17435ae03370e2c3008e8f74b196..22eac673a41e76874b9d9f04e31d313eb1fb09fa 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -177,6 +177,10 @@ func (m *UI) Init() tea.Cmd { return tea.Batch(cmds...) } +// sessionLoadedDoneMsg indicates that session loading and message appending is +// done. +type sessionLoadedDoneMsg struct{} + // Update handles updates to the UI model. func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -194,7 +198,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for _, message := range msgs { m.chat.AppendMessage(message) } + // Notify that session loading is done to scroll to bottom. This is + // needed because we need to draw the chat list first before we can + // scroll to bottom. + cmds = append(cmds, func() tea.Msg { + return sessionLoadedDoneMsg{} + }) + case sessionLoadedDoneMsg: m.chat.ScrollToBottom() + m.chat.SelectLast() case sessionFilesLoadedMsg: m.sessionFiles = msg.files case pubsub.Event[history.File]: From 82bc601d267ea1e9b8b1fb2d966a2a965aa52782 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 5 Dec 2025 15:38:29 -0500 Subject: [PATCH 029/335] feat(ui): add common utils and refactor chat message items and tools --- internal/ui/common/diff.go | 16 + internal/ui/common/highlight.go | 57 ++ internal/ui/model/chat.go | 24 +- internal/ui/model/items.go | 753 ++++++++++++++++++++++++++ internal/ui/model/ui.go | 17 +- internal/ui/styles/styles.go | 286 +++++++++- internal/ui/toolrender/render.go | 889 +++++++++++++++++++++++++++++++ 7 files changed, 2008 insertions(+), 34 deletions(-) create mode 100644 internal/ui/common/diff.go create mode 100644 internal/ui/common/highlight.go create mode 100644 internal/ui/model/items.go create mode 100644 internal/ui/toolrender/render.go diff --git a/internal/ui/common/diff.go b/internal/ui/common/diff.go new file mode 100644 index 0000000000000000000000000000000000000000..8007cebce93a0d0833be779eb11cbb703bc8c1d6 --- /dev/null +++ b/internal/ui/common/diff.go @@ -0,0 +1,16 @@ +package common + +import ( + "github.com/alecthomas/chroma/v2" + "github.com/charmbracelet/crush/internal/tui/exp/diffview" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// DiffFormatter returns a diff formatter with the given styles that can be +// used to format diff outputs. +func DiffFormatter(s *styles.Styles) *diffview.DiffView { + formatDiff := diffview.New() + style := chroma.MustNewStyle("crush", s.ChromaTheme()) + diff := formatDiff.ChromaStyle(style).Style(s.Diff).TabWidth(4) + return diff +} diff --git a/internal/ui/common/highlight.go b/internal/ui/common/highlight.go new file mode 100644 index 0000000000000000000000000000000000000000..642a7859d110a86af57feeb447907612a6b12098 --- /dev/null +++ b/internal/ui/common/highlight.go @@ -0,0 +1,57 @@ +package common + +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/ui/styles" +) + +// SyntaxHighlight applies syntax highlighting to the given source code based +// on the file name and background color. It returns the highlighted code as a +// string. +func SyntaxHighlight(st *styles.Styles, 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", st.ChromaTheme()) + + // 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/ui/model/chat.go b/internal/ui/model/chat.go index bc38a250876eab39eba7f1ffdba124741ee2ed5d..9b3f4967f374380b46bfcc813136597c3812c26f 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -65,7 +65,7 @@ type ChatNoContentItem struct { func NewChatNoContentItem(t *styles.Styles) *ChatNoContentItem { c := new(ChatNoContentItem) c.StringItem = list.NewStringItem("No message content"). - WithFocusStyles(&t.Chat.NoContentMessage, &t.Chat.NoContentMessage) + WithFocusStyles(&t.Chat.Message.NoContent, &t.Chat.Message.NoContent) return c } @@ -88,7 +88,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem switch msg.Role { case message.User: item := list.NewMarkdownItem(msg.Content().String()). - WithFocusStyles(&t.Chat.UserMessageFocused, &t.Chat.UserMessageBlurred) + WithFocusStyles(&t.Chat.Message.UserFocused, &t.Chat.Message.UserBlurred) item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection)) // TODO: Add attachments c.item = item @@ -102,13 +102,13 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem reasoningThinking := strings.TrimSpace(reasoningContent.Thinking) if finished && content == "" && finishedData.Reason == message.FinishReasonError { - tag := t.Chat.ErrorTag.Render("ERROR") - title := t.Chat.ErrorTitle.Render(finishedData.Message) - details := t.Chat.ErrorDetails.Render(finishedData.Details) + tag := t.Chat.Message.ErrorTag.Render("ERROR") + title := t.Chat.Message.ErrorTitle.Render(finishedData.Message) + details := t.Chat.Message.ErrorDetails.Render(finishedData.Details) errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details) item := list.NewStringItem(errContent). - WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred) + WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred) c.item = item @@ -136,7 +136,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem } item := list.NewMarkdownItem(strings.Join(parts, "\n")). - WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred) + WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred) item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection)) c.item = item @@ -234,12 +234,10 @@ func (m *Chat) PrependItem(item list.Item) { m.list.PrependItem(item) } -// AppendMessage appends a new message item to the chat list. -func (m *Chat) AppendMessage(msg message.Message) { - if msg.ID == "" { - m.AppendItem(NewChatNoContentItem(m.com.Styles)) - } else { - m.AppendItem(NewChatMessageItem(m.com.Styles, msg)) +// AppendMessages appends a new message item to the chat list. +func (m *Chat) AppendMessages(msgs ...MessageItem) { + for _, msg := range msgs { + m.AppendItem(msg) } } diff --git a/internal/ui/model/items.go b/internal/ui/model/items.go new file mode 100644 index 0000000000000000000000000000000000000000..7df9f8c052400da3e9198513dcd37bc4d67d41ec --- /dev/null +++ b/internal/ui/model/items.go @@ -0,0 +1,753 @@ +package model + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "charm.land/lipgloss/v2" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" + + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/ui/toolrender" +) + +// Identifiable is an interface for items that can provide a unique identifier. +type Identifiable interface { + ID() string +} + +// MessageItem represents a [message.Message] item that can be displayed in the +// UI and be part of a [list.List] identifiable by a unique ID. +type MessageItem interface { + list.Item + list.Focusable + list.Highlightable + Identifiable +} + +// MessageContentItem represents rendered message content (text, markdown, errors, etc). +type MessageContentItem struct { + list.BaseFocusable + list.BaseHighlightable + id string + content string + isMarkdown bool + maxWidth int + cache map[int]string // Cache for rendered content at different widths + sty *styles.Styles +} + +// NewMessageContentItem creates a new message content item. +func NewMessageContentItem(id, content string, isMarkdown bool, sty *styles.Styles) *MessageContentItem { + m := &MessageContentItem{ + id: id, + content: content, + isMarkdown: isMarkdown, + maxWidth: 120, + cache: make(map[int]string), + sty: sty, + } + m.InitHighlight() + return m +} + +// ID implements Identifiable. +func (m *MessageContentItem) ID() string { + return m.id +} + +// Height implements list.Item. +func (m *MessageContentItem) Height(width int) int { + // Calculate content width accounting for frame size + contentWidth := width + if style := m.CurrentStyle(); style != nil { + contentWidth -= style.GetHorizontalFrameSize() + } + + rendered := m.render(contentWidth) + + // Apply focus/blur styling if configured to get accurate height + if style := m.CurrentStyle(); style != nil { + rendered = style.Render(rendered) + } + + return strings.Count(rendered, "\n") + 1 +} + +// Draw implements list.Item. +func (m *MessageContentItem) Draw(scr uv.Screen, area uv.Rectangle) { + width := area.Dx() + height := area.Dy() + + // Calculate content width accounting for frame size + contentWidth := width + style := m.CurrentStyle() + if style != nil { + contentWidth -= style.GetHorizontalFrameSize() + } + + rendered := m.render(contentWidth) + + // Apply focus/blur styling if configured + if style != nil { + rendered = style.Render(rendered) + } + + // Create temp buffer to draw content with highlighting + tempBuf := uv.NewScreenBuffer(width, height) + + // Draw the rendered content to temp buffer + styled := uv.NewStyledString(rendered) + styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) + + // Apply highlighting if active + m.ApplyHighlight(&tempBuf, width, height, style) + + // Copy temp buffer to actual screen at the target area + tempBuf.Draw(scr, area) +} + +// render renders the content at the given width, using cache if available. +func (m *MessageContentItem) render(width int) string { + // Cap width to maxWidth for markdown + cappedWidth := width + if m.isMarkdown { + cappedWidth = min(width, m.maxWidth) + } + + // Check cache first + if cached, ok := m.cache[cappedWidth]; ok { + return cached + } + + // Not cached - render now + var rendered string + if m.isMarkdown { + renderer := common.MarkdownRenderer(m.sty, cappedWidth) + result, err := renderer.Render(m.content) + if err != nil { + rendered = m.content + } else { + rendered = strings.TrimSuffix(result, "\n") + } + } else { + rendered = m.content + } + + // Cache the result + m.cache[cappedWidth] = rendered + return rendered +} + +// SetHighlight implements list.Highlightable and extends BaseHighlightable. +func (m *MessageContentItem) SetHighlight(startLine, startCol, endLine, endCol int) { + m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) + // Clear cache when highlight changes + m.cache = make(map[int]string) +} + +// ToolCallItem represents a rendered tool call with its header and content. +type ToolCallItem struct { + list.BaseFocusable + list.BaseHighlightable + id string + toolCall message.ToolCall + toolResult message.ToolResult + cancelled bool + isNested bool + maxWidth int + cache map[int]cachedToolRender // Cache for rendered content at different widths + cacheKey string // Key to invalidate cache when content changes + sty *styles.Styles +} + +// cachedToolRender stores both the rendered string and its height. +type cachedToolRender struct { + content string + height int +} + +// NewToolCallItem creates a new tool call item. +func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool, isNested bool, sty *styles.Styles) *ToolCallItem { + t := &ToolCallItem{ + id: id, + toolCall: toolCall, + toolResult: toolResult, + cancelled: cancelled, + isNested: isNested, + maxWidth: 120, + cache: make(map[int]cachedToolRender), + cacheKey: generateCacheKey(toolCall, toolResult, cancelled), + sty: sty, + } + t.InitHighlight() + return t +} + +// generateCacheKey creates a key that changes when tool call content changes. +func generateCacheKey(toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool) string { + // Simple key based on result state - when result arrives or changes, key changes + return fmt.Sprintf("%s:%s:%v", toolCall.ID, toolResult.ToolCallID, cancelled) +} + +// ID implements Identifiable. +func (t *ToolCallItem) ID() string { + return t.id +} + +// Height implements list.Item. +func (t *ToolCallItem) Height(width int) int { + // Calculate content width accounting for frame size + contentWidth := width + frameSize := 0 + if style := t.CurrentStyle(); style != nil { + frameSize = style.GetHorizontalFrameSize() + contentWidth -= frameSize + } + + cached := t.renderCached(contentWidth) + + // Add frame size to height if needed + height := cached.height + if frameSize > 0 { + // Frame can add to height (borders, padding) + if style := t.CurrentStyle(); style != nil { + // Quick render to get accurate height with frame + rendered := style.Render(cached.content) + height = strings.Count(rendered, "\n") + 1 + } + } + + return height +} + +// Draw implements list.Item. +func (t *ToolCallItem) Draw(scr uv.Screen, area uv.Rectangle) { + width := area.Dx() + height := area.Dy() + + // Calculate content width accounting for frame size + contentWidth := width + style := t.CurrentStyle() + if style != nil { + contentWidth -= style.GetHorizontalFrameSize() + } + + cached := t.renderCached(contentWidth) + rendered := cached.content + + if style != nil { + rendered = style.Render(rendered) + } + + tempBuf := uv.NewScreenBuffer(width, height) + styled := uv.NewStyledString(rendered) + styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) + + t.ApplyHighlight(&tempBuf, width, height, style) + tempBuf.Draw(scr, area) +} + +// renderCached renders the tool call at the given width with caching. +func (t *ToolCallItem) renderCached(width int) cachedToolRender { + cappedWidth := min(width, t.maxWidth) + + // Check if we have a valid cache entry + if cached, ok := t.cache[cappedWidth]; ok { + return cached + } + + // Render the tool call + ctx := &toolrender.RenderContext{ + Call: t.toolCall, + Result: t.toolResult, + Cancelled: t.cancelled, + IsNested: t.isNested, + Width: cappedWidth, + Styles: t.sty, + } + + rendered := toolrender.Render(ctx) + height := strings.Count(rendered, "\n") + 1 + + cached := cachedToolRender{ + content: rendered, + height: height, + } + t.cache[cappedWidth] = cached + return cached +} + +// SetHighlight implements list.Highlightable. +func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) { + t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) + // Clear cache when highlight changes + t.cache = make(map[int]cachedToolRender) +} + +// UpdateResult updates the tool result and invalidates the cache if needed. +func (t *ToolCallItem) UpdateResult(result message.ToolResult) { + newKey := generateCacheKey(t.toolCall, result, t.cancelled) + if newKey != t.cacheKey { + t.toolResult = result + t.cacheKey = newKey + t.cache = make(map[int]cachedToolRender) + } +} + +// AttachmentItem represents a file attachment in a user message. +type AttachmentItem struct { + list.BaseFocusable + list.BaseHighlightable + id string + filename string + path string + sty *styles.Styles +} + +// NewAttachmentItem creates a new attachment item. +func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *AttachmentItem { + a := &AttachmentItem{ + id: id, + filename: filename, + path: path, + sty: sty, + } + a.InitHighlight() + return a +} + +// ID implements Identifiable. +func (a *AttachmentItem) ID() string { + return a.id +} + +// Height implements list.Item. +func (a *AttachmentItem) Height(width int) int { + return 1 +} + +// Draw implements list.Item. +func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) { + width := area.Dx() + height := area.Dy() + + // Calculate content width accounting for frame size + contentWidth := width + style := a.CurrentStyle() + if style != nil { + contentWidth -= style.GetHorizontalFrameSize() + } + + const maxFilenameWidth = 10 + content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf( + " %s %s ", + styles.DocumentIcon, + ansi.Truncate(a.filename, maxFilenameWidth, "..."), + )) + + if style != nil { + content = style.Render(content) + } + + tempBuf := uv.NewScreenBuffer(width, height) + styled := uv.NewStyledString(content) + styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) + + a.ApplyHighlight(&tempBuf, width, height, style) + tempBuf.Draw(scr, area) +} + +// ThinkingItem represents thinking/reasoning content in assistant messages. +type ThinkingItem struct { + list.BaseFocusable + list.BaseHighlightable + id string + thinking string + duration time.Duration + finished bool + maxWidth int + cache map[int]string + sty *styles.Styles +} + +// NewThinkingItem creates a new thinking item. +func NewThinkingItem(id, thinking string, duration time.Duration, finished bool, sty *styles.Styles) *ThinkingItem { + t := &ThinkingItem{ + id: id, + thinking: thinking, + duration: duration, + finished: finished, + maxWidth: 120, + cache: make(map[int]string), + sty: sty, + } + t.InitHighlight() + return t +} + +// ID implements Identifiable. +func (t *ThinkingItem) ID() string { + return t.id +} + +// Height implements list.Item. +func (t *ThinkingItem) Height(width int) int { + // Calculate content width accounting for frame size + contentWidth := width + if style := t.CurrentStyle(); style != nil { + contentWidth -= style.GetHorizontalFrameSize() + } + + rendered := t.render(contentWidth) + return strings.Count(rendered, "\n") + 1 +} + +// Draw implements list.Item. +func (t *ThinkingItem) Draw(scr uv.Screen, area uv.Rectangle) { + width := area.Dx() + height := area.Dy() + + // Calculate content width accounting for frame size + contentWidth := width + style := t.CurrentStyle() + if style != nil { + contentWidth -= style.GetHorizontalFrameSize() + } + + rendered := t.render(contentWidth) + + if style != nil { + rendered = style.Render(rendered) + } + + tempBuf := uv.NewScreenBuffer(width, height) + styled := uv.NewStyledString(rendered) + styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) + + t.ApplyHighlight(&tempBuf, width, height, style) + tempBuf.Draw(scr, area) +} + +// render renders the thinking content. +func (t *ThinkingItem) render(width int) string { + cappedWidth := min(width, t.maxWidth) + + if cached, ok := t.cache[cappedWidth]; ok { + return cached + } + + renderer := common.PlainMarkdownRenderer(cappedWidth - 1) + rendered, err := renderer.Render(t.thinking) + if err != nil { + // Fallback to line-by-line rendering + lines := strings.Split(t.thinking, "\n") + var content strings.Builder + lineStyle := t.sty.PanelMuted + for i, line := range lines { + if line == "" { + continue + } + content.WriteString(lineStyle.Width(cappedWidth).Render(line)) + if i < len(lines)-1 { + content.WriteString("\n") + } + } + rendered = content.String() + } + + fullContent := strings.TrimSpace(rendered) + + // Add footer if finished + if t.finished && t.duration > 0 { + footer := t.sty.Chat.Message.ThinkingFooter.Render(fmt.Sprintf("Thought for %s", t.duration.String())) + fullContent = lipgloss.JoinVertical(lipgloss.Left, fullContent, "", footer) + } + + result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent) + + t.cache[cappedWidth] = result + return result +} + +// SetHighlight implements list.Highlightable. +func (t *ThinkingItem) SetHighlight(startLine, startCol, endLine, endCol int) { + t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) + t.cache = make(map[int]string) +} + +// SectionHeaderItem represents a section header (e.g., assistant info). +type SectionHeaderItem struct { + list.BaseFocusable + list.BaseHighlightable + id string + modelName string + duration time.Duration + isSectionHeader bool + sty *styles.Styles +} + +// NewSectionHeaderItem creates a new section header item. +func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *styles.Styles) *SectionHeaderItem { + s := &SectionHeaderItem{ + id: id, + modelName: modelName, + duration: duration, + isSectionHeader: true, + sty: sty, + } + s.InitHighlight() + return s +} + +// ID implements Identifiable. +func (s *SectionHeaderItem) ID() string { + return s.id +} + +// IsSectionHeader returns true if this is a section header. +func (s *SectionHeaderItem) IsSectionHeader() bool { + return s.isSectionHeader +} + +// Height implements list.Item. +func (s *SectionHeaderItem) Height(width int) int { + return 1 +} + +// Draw implements list.Item. +func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) { + width := area.Dx() + height := area.Dy() + + // Calculate content width accounting for frame size + contentWidth := width + style := s.CurrentStyle() + if style != nil { + contentWidth -= style.GetHorizontalFrameSize() + } + + infoMsg := s.sty.Subtle.Render(s.duration.String()) + icon := s.sty.Subtle.Render(styles.ModelIcon) + modelFormatted := s.sty.Muted.Render(s.modelName) + content := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg) + + content = s.sty.Chat.Message.SectionHeader.Render(content) + + if style != nil { + content = style.Render(content) + } + + tempBuf := uv.NewScreenBuffer(width, height) + styled := uv.NewStyledString(content) + styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) + + s.ApplyHighlight(&tempBuf, width, height, style) + tempBuf.Draw(scr, area) +} + +// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns +// all parts of the message as [MessageItem]s. +// +// For assistant messages with tool calls, pass a toolResults map to link results. +// Use BuildToolResultMap to create this map from all messages in a session. +func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { + sty := styles.DefaultStyles() + var items []MessageItem + + // Skip tool result messages - they're displayed inline with tool calls + if msg.Role == message.Tool { + return items + } + + // Create base styles for the message + var focusStyle, blurStyle lipgloss.Style + if msg.Role == message.User { + focusStyle = sty.Chat.Message.UserFocused + blurStyle = sty.Chat.Message.UserBlurred + } else { + focusStyle = sty.Chat.Message.AssistantFocused + blurStyle = sty.Chat.Message.AssistantBlurred + } + + // Process user messages + if msg.Role == message.User { + // Add main text content + content := msg.Content().String() + if content != "" { + item := NewMessageContentItem( + fmt.Sprintf("%s-content", msg.ID), + content, + true, // User messages are markdown + &sty, + ) + item.SetFocusStyles(&focusStyle, &blurStyle) + items = append(items, item) + } + + // Add attachments + for i, attachment := range msg.BinaryContent() { + filename := filepath.Base(attachment.Path) + item := NewAttachmentItem( + fmt.Sprintf("%s-attachment-%d", msg.ID, i), + filename, + attachment.Path, + &sty, + ) + item.SetFocusStyles(&focusStyle, &blurStyle) + items = append(items, item) + } + + return items + } + + // Process assistant messages + if msg.Role == message.Assistant { + // Check if we need to add a section header + finishData := msg.FinishPart() + if finishData != nil && msg.Model != "" { + model := config.Get().GetModel(msg.Provider, msg.Model) + modelName := "Unknown Model" + if model != nil { + modelName = model.Name + } + + // Calculate duration (this would need the last user message time) + duration := time.Duration(0) + if finishData.Time > 0 { + duration = time.Duration(finishData.Time-msg.CreatedAt) * time.Second + } + + header := NewSectionHeaderItem( + fmt.Sprintf("%s-header", msg.ID), + modelName, + duration, + &sty, + ) + items = append(items, header) + } + + // Add thinking content if present + reasoning := msg.ReasoningContent() + if strings.TrimSpace(reasoning.Thinking) != "" { + duration := time.Duration(0) + if reasoning.StartedAt > 0 && reasoning.FinishedAt > 0 { + duration = time.Duration(reasoning.FinishedAt-reasoning.StartedAt) * time.Second + } + + item := NewThinkingItem( + fmt.Sprintf("%s-thinking", msg.ID), + reasoning.Thinking, + duration, + reasoning.FinishedAt > 0, + &sty, + ) + item.SetFocusStyles(&focusStyle, &blurStyle) + items = append(items, item) + } + + // Add main text content + content := msg.Content().String() + finished := msg.IsFinished() + + // Handle special finish states + if finished && content == "" && finishData != nil { + switch finishData.Reason { + case message.FinishReasonEndTurn: + // No content to show + case message.FinishReasonCanceled: + item := NewMessageContentItem( + fmt.Sprintf("%s-content", msg.ID), + "*Canceled*", + true, + &sty, + ) + item.SetFocusStyles(&focusStyle, &blurStyle) + items = append(items, item) + case message.FinishReasonError: + // Render error + errTag := sty.Chat.Message.ErrorTag.Render("ERROR") + truncated := ansi.Truncate(finishData.Message, 100, "...") + title := fmt.Sprintf("%s %s", errTag, sty.Chat.Message.ErrorTitle.Render(truncated)) + details := sty.Chat.Message.ErrorDetails.Render(finishData.Details) + errorContent := fmt.Sprintf("%s\n\n%s", title, details) + + item := NewMessageContentItem( + fmt.Sprintf("%s-error", msg.ID), + errorContent, + false, + &sty, + ) + item.SetFocusStyles(&focusStyle, &blurStyle) + items = append(items, item) + } + } else if content != "" { + item := NewMessageContentItem( + fmt.Sprintf("%s-content", msg.ID), + content, + true, // Assistant messages are markdown + &sty, + ) + item.SetFocusStyles(&focusStyle, &blurStyle) + items = append(items, item) + } + + // Add tool calls + toolCalls := msg.ToolCalls() + + // Use passed-in tool results map (if nil, use empty map) + resultMap := toolResults + if resultMap == nil { + resultMap = make(map[string]message.ToolResult) + } + + for _, tc := range toolCalls { + result, hasResult := resultMap[tc.ID] + if !hasResult { + result = message.ToolResult{} + } + + item := NewToolCallItem( + fmt.Sprintf("%s-tool-%s", msg.ID, tc.ID), + tc, + result, + false, // cancelled state would need to be tracked separately + false, // nested state would be detected from tool results + &sty, + ) + + // Tool calls use muted style with optional focus border + item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred) + + items = append(items, item) + } + + return items + } + + return items +} + +// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages. +// Tool result messages (role == message.Tool) contain the results that should be linked +// to tool calls in assistant messages. +func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult { + resultMap := make(map[string]message.ToolResult) + for _, msg := range messages { + if msg.Role == message.Tool { + for _, result := range msg.ToolResults() { + if result.ToolCallID != "" { + resultMap[result.ToolCallID] = result + } + } + } + } + return resultMap +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 22eac673a41e76874b9d9f04e31d313eb1fb09fa..09c3155dfd597ab55175f0ac079ce03e69494b5b 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -18,6 +18,7 @@ import ( "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/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" @@ -195,9 +196,21 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.session = &msg.sess // Load the last 20 messages from this session. msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID) - for _, message := range msgs { - m.chat.AppendMessage(message) + + // Build tool result map to link tool calls with their results + msgPtrs := make([]*message.Message, len(msgs)) + for i := range msgs { + msgPtrs[i] = &msgs[i] + } + toolResultMap := BuildToolResultMap(msgPtrs) + + // Add messages to chat with linked tool results + items := make([]MessageItem, 0, len(msgs)*2) + for _, msg := range msgPtrs { + items = append(items, GetMessageItems(msg, toolResultMap)...) } + m.chat.AppendMessages(items...) + // Notify that session loading is done to scroll to bottom. This is // needed because we need to draw the chat list first before we can // scroll to bottom. diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 59be32af0deabfcfd749f72edc7d4493b8ed8870..97d3b4950f2672481a949a7538c00fa2579c3065 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -9,6 +9,7 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/alecthomas/chroma/v2" "github.com/charmbracelet/crush/internal/tui/exp/diffview" "github.com/charmbracelet/glamour/v2/ansi" "github.com/charmbracelet/x/exp/charmtone" @@ -117,6 +118,7 @@ type Styles struct { // Background Background color.Color + // Logo LogoFieldColor color.Color LogoTitleColorA color.Color @@ -124,6 +126,31 @@ type Styles struct { LogoCharmColor color.Color LogoVersionColor color.Color + // Colors - semantic colors for tool rendering. + Primary color.Color + Secondary color.Color + Tertiary 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 + Border color.Color + BorderColor color.Color // Border focus color + Warning color.Color + Info color.Color + White color.Color + BlueLight color.Color + Blue color.Color + Green color.Color + GreenDark color.Color + Red color.Color + RedDark color.Color + Yellow color.Color + // Section Title Section struct { Title lipgloss.Style @@ -154,16 +181,120 @@ type Styles struct { // Chat Chat struct { - UserMessageBlurred lipgloss.Style - UserMessageFocused lipgloss.Style - AssistantMessageBlurred lipgloss.Style - AssistantMessageFocused lipgloss.Style - NoContentMessage lipgloss.Style - ThinkingMessage lipgloss.Style - - ErrorTag lipgloss.Style - ErrorTitle lipgloss.Style - ErrorDetails lipgloss.Style + // Message item styles + Message struct { + UserBlurred lipgloss.Style + UserFocused lipgloss.Style + AssistantBlurred lipgloss.Style + AssistantFocused lipgloss.Style + NoContent lipgloss.Style + Thinking lipgloss.Style + ErrorTag lipgloss.Style + ErrorTitle lipgloss.Style + ErrorDetails lipgloss.Style + Attachment lipgloss.Style + ToolCallFocused lipgloss.Style + ToolCallBlurred lipgloss.Style + ThinkingFooter lipgloss.Style + SectionHeader lipgloss.Style + } + } + + // Tool - styles for tool call rendering + Tool struct { + // Icon styles with tool status + IconPending lipgloss.Style // Pending operation icon + IconSuccess lipgloss.Style // Successful operation icon + IconError lipgloss.Style // Error operation icon + IconCancelled lipgloss.Style // Cancelled operation icon + + // Tool name styles + NameNormal lipgloss.Style // Normal tool name + NameNested lipgloss.Style // Nested tool name + + // Parameter list styles + ParamMain lipgloss.Style // Main parameter + ParamKey lipgloss.Style // Parameter keys + + // Content rendering styles + ContentLine lipgloss.Style // Individual content line with background and width + ContentTruncation lipgloss.Style // Truncation message "… (N lines)" + ContentCodeLine lipgloss.Style // Code line with background and width + ContentCodeBg color.Color // Background color for syntax highlighting + BodyPadding lipgloss.Style // Body content padding (PaddingLeft(2)) + + // Deprecated - kept for backward compatibility + ContentBg lipgloss.Style // Content background + ContentText lipgloss.Style // Content text + ContentLineNumber lipgloss.Style // Line numbers in code + + // State message styles + StateWaiting lipgloss.Style // "Waiting for tool response..." + StateCancelled lipgloss.Style // "Canceled." + + // Error styles + ErrorTag lipgloss.Style // ERROR tag + ErrorMessage lipgloss.Style // Error message text + + // Diff styles + DiffTruncation lipgloss.Style // Diff truncation message with padding + + // Multi-edit note styles + NoteTag lipgloss.Style // NOTE tag (yellow background) + NoteMessage lipgloss.Style // Note message text + + // Job header styles (for bash jobs) + JobIconPending lipgloss.Style // Pending job icon (green dark) + JobIconError lipgloss.Style // Error job icon (red dark) + JobIconSuccess lipgloss.Style // Success job icon (green) + JobToolName lipgloss.Style // Job tool name "Bash" (blue) + JobAction lipgloss.Style // Action text (Start, Output, Kill) + JobPID lipgloss.Style // PID text + JobDescription lipgloss.Style // Description text + + // Agent task styles + AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold) + AgentPrompt lipgloss.Style // Agent prompt text + } +} + +// ChromaTheme converts the current markdown chroma styles to a chroma +// StyleEntries map. +func (s *Styles) ChromaTheme() chroma.StyleEntries { + rules := 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), } } @@ -202,6 +333,7 @@ func DefaultStyles() Styles { blue = charmtone.Malibu // yellow = charmtone.Mustard + yellow = charmtone.Mustard // citron = charmtone.Citron green = charmtone.Julep @@ -222,6 +354,31 @@ func DefaultStyles() Styles { s.Background = bgBase + // Populate color fields + s.Primary = primary + s.Secondary = secondary + s.Tertiary = tertiary + s.BgBase = bgBase + s.BgBaseLighter = bgBaseLighter + s.BgSubtle = bgSubtle + s.BgOverlay = bgOverlay + s.FgBase = fgBase + s.FgMuted = fgMuted + s.FgHalfMuted = fgHalfMuted + s.FgSubtle = fgSubtle + s.Border = border + s.BorderColor = borderFocus + s.Warning = warning + s.Info = info + s.White = white + s.BlueLight = blueLight + s.Blue = blue + s.Green = green + s.GreenDark = greenDark + s.Red = red + s.RedDark = redDark + s.Yellow = yellow + s.TextInput = textinput.Styles{ Focused: textinput.StyleState{ Text: base, @@ -580,6 +737,54 @@ func DefaultStyles() Styles { s.ToolCallCancelled = s.Muted.SetString(ToolPending) s.EarlyStateMessage = s.Subtle.PaddingLeft(2) + // Tool rendering styles + s.Tool.IconPending = base.Foreground(greenDark).SetString(ToolPending) + s.Tool.IconSuccess = base.Foreground(green).SetString(ToolSuccess) + s.Tool.IconError = base.Foreground(redDark).SetString(ToolError) + s.Tool.IconCancelled = s.Muted.SetString(ToolPending) + + s.Tool.NameNormal = base.Foreground(blue) + s.Tool.NameNested = base.Foreground(fgHalfMuted) + + s.Tool.ParamMain = s.Subtle + s.Tool.ParamKey = s.Subtle + + // 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(bgBaseLighter) + s.Tool.ContentCodeBg = bgBase + s.Tool.BodyPadding = base.PaddingLeft(2) + + // Deprecated - kept for backward compatibility + s.Tool.ContentBg = s.Muted.Background(bgBaseLighter) + s.Tool.ContentText = s.Muted + s.Tool.ContentLineNumber = s.Subtle + + s.Tool.StateWaiting = base.Foreground(fgSubtle) + s.Tool.StateCancelled = base.Foreground(fgSubtle) + + s.Tool.ErrorTag = base.Padding(0, 1).Background(red).Foreground(white) + s.Tool.ErrorMessage = base.Foreground(fgHalfMuted) + + // Diff and multi-edit styles + s.Tool.DiffTruncation = s.Muted.Background(bgBaseLighter).PaddingLeft(2) + s.Tool.NoteTag = base.Padding(0, 1).Background(yellow).Foreground(white) + s.Tool.NoteMessage = base.Foreground(fgHalfMuted) + + // Job header styles + s.Tool.JobIconPending = base.Foreground(greenDark) + s.Tool.JobIconError = base.Foreground(redDark) + s.Tool.JobIconSuccess = base.Foreground(green) + s.Tool.JobToolName = base.Foreground(blue) + s.Tool.JobAction = base.Foreground(fgHalfMuted) + s.Tool.JobPID = s.Subtle + s.Tool.JobDescription = s.Subtle + + // Agent task styles + s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white) + s.Tool.AgentPrompt = s.Muted + // Buttons s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary) s.ButtonBlur = s.Base.Background(bgSubtle) @@ -633,19 +838,29 @@ func DefaultStyles() Styles { Left: "▌", } - s.Chat.NoContentMessage = lipgloss.NewStyle().Foreground(fgBase) - s.Chat.UserMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true). + s.Chat.Message.NoContent = lipgloss.NewStyle().Foreground(fgBase) + s.Chat.Message.UserBlurred = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true). BorderForeground(primary).BorderStyle(normalBorder) - s.Chat.UserMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true). + s.Chat.Message.UserFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true). BorderForeground(primary).BorderStyle(messageFocussedBorder) - s.Chat.AssistantMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(2) - s.Chat.AssistantMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true). + s.Chat.Message.AssistantBlurred = s.Chat.Message.NoContent.PaddingLeft(2) + s.Chat.Message.AssistantFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true). BorderForeground(greenDark).BorderStyle(messageFocussedBorder) - s.Chat.ThinkingMessage = lipgloss.NewStyle().MaxHeight(10) - s.Chat.ErrorTag = lipgloss.NewStyle().Padding(0, 1). + s.Chat.Message.Thinking = lipgloss.NewStyle().MaxHeight(10) + s.Chat.Message.ErrorTag = lipgloss.NewStyle().Padding(0, 1). Background(red).Foreground(white) - s.Chat.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted) - s.Chat.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle) + s.Chat.Message.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted) + s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle) + + // Message item styles + s.Chat.Message.Attachment = lipgloss.NewStyle().MarginLeft(1).Background(bgSubtle) + s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1). + BorderStyle(messageFocussedBorder). + BorderLeft(true). + BorderForeground(greenDark) + s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2) + s.Chat.Message.ThinkingFooter = s.Base + s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2) // Text selection. s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple) @@ -657,3 +872,36 @@ func DefaultStyles() Styles { func boolPtr(b bool) *bool { return &b } func stringPtr(s string) *string { return &s } func uintPtr(u uint) *uint { return &u } +func chromaStyle(style ansi.StylePrimitive) string { + var s string + + 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 +} diff --git a/internal/ui/toolrender/render.go b/internal/ui/toolrender/render.go new file mode 100644 index 0000000000000000000000000000000000000000..18895908583322a06e58de553a6291ba9f7b3448 --- /dev/null +++ b/internal/ui/toolrender/render.go @@ -0,0 +1,889 @@ +package toolrender + +import ( + "cmp" + "encoding/json" + "fmt" + "strings" + + "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/message" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// responseContextHeight limits the number of lines displayed in tool output. +const responseContextHeight = 10 + +// RenderContext provides the context needed for rendering a tool call. +type RenderContext struct { + Call message.ToolCall + Result message.ToolResult + Cancelled bool + IsNested bool + Width int + Styles *styles.Styles +} + +// TextWidth returns the available width for content accounting for borders. +func (rc *RenderContext) TextWidth() int { + if rc.IsNested { + return rc.Width - 6 + } + return rc.Width - 5 +} + +// Fit truncates content to fit within the specified width with ellipsis. +func (rc *RenderContext) Fit(content string, width int) string { + lineStyle := rc.Styles.Muted + dots := lineStyle.Render("…") + return ansi.Truncate(content, width, dots) +} + +// Render renders a tool call using the appropriate renderer based on tool name. +func Render(ctx *RenderContext) string { + switch ctx.Call.Name { + case tools.ViewToolName: + return renderView(ctx) + case tools.EditToolName: + return renderEdit(ctx) + case tools.MultiEditToolName: + return renderMultiEdit(ctx) + case tools.WriteToolName: + return renderWrite(ctx) + case tools.BashToolName: + return renderBash(ctx) + case tools.JobOutputToolName: + return renderJobOutput(ctx) + case tools.JobKillToolName: + return renderJobKill(ctx) + case tools.FetchToolName: + return renderSimpleFetch(ctx) + case tools.AgenticFetchToolName: + return renderAgenticFetch(ctx) + case tools.WebFetchToolName: + return renderWebFetch(ctx) + case tools.DownloadToolName: + return renderDownload(ctx) + case tools.GlobToolName: + return renderGlob(ctx) + case tools.GrepToolName: + return renderGrep(ctx) + case tools.LSToolName: + return renderLS(ctx) + case tools.SourcegraphToolName: + return renderSourcegraph(ctx) + case tools.DiagnosticsToolName: + return renderDiagnostics(ctx) + case agent.AgentToolName: + return renderAgent(ctx) + default: + return renderGeneric(ctx) + } +} + +// Helper functions + +func unmarshalParams(input string, target any) error { + return json.Unmarshal([]byte(input), target) +} + +type paramBuilder struct { + args []string +} + +func newParamBuilder() *paramBuilder { + return ¶mBuilder{args: make([]string, 0)} +} + +func (pb *paramBuilder) addMain(value string) *paramBuilder { + if value != "" { + pb.args = append(pb.args, value) + } + return pb +} + +func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder { + if value != "" { + pb.args = append(pb.args, key, value) + } + return pb +} + +func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder { + if value { + pb.args = append(pb.args, key, "true") + } + return pb +} + +func (pb *paramBuilder) build() []string { + return pb.args +} + +func formatNonZero[T comparable](value T) string { + var zero T + if value == zero { + return "" + } + return fmt.Sprintf("%v", value) +} + +func makeHeader(ctx *RenderContext, toolName string, args []string) string { + if ctx.IsNested { + return makeNestedHeader(ctx, toolName, args) + } + s := ctx.Styles + var icon string + if ctx.Result.ToolCallID != "" { + if ctx.Result.IsError { + icon = s.Tool.IconError.Render() + } else { + icon = s.Tool.IconSuccess.Render() + } + } else if ctx.Cancelled { + icon = s.Tool.IconCancelled.Render() + } else { + icon = s.Tool.IconPending.Render() + } + tool := s.Tool.NameNormal.Render(toolName) + prefix := fmt.Sprintf("%s %s ", icon, tool) + return prefix + renderParamList(ctx, false, ctx.TextWidth()-lipgloss.Width(prefix), args...) +} + +func makeNestedHeader(ctx *RenderContext, toolName string, args []string) string { + s := ctx.Styles + var icon string + if ctx.Result.ToolCallID != "" { + if ctx.Result.IsError { + icon = s.Tool.IconError.Render() + } else { + icon = s.Tool.IconSuccess.Render() + } + } else if ctx.Cancelled { + icon = s.Tool.IconCancelled.Render() + } else { + icon = s.Tool.IconPending.Render() + } + tool := s.Tool.NameNested.Render(toolName) + prefix := fmt.Sprintf("%s %s ", icon, tool) + return prefix + renderParamList(ctx, true, ctx.TextWidth()-lipgloss.Width(prefix), args...) +} + +func renderParamList(ctx *RenderContext, nested bool, paramsWidth int, params ...string) string { + s := ctx.Styles + 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 s.Tool.ParamMain.Render(mainParam) + } + otherParams := params[1:] + 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 + if remainingWidth < 30 { + return s.Tool.ParamMain.Render(mainParam) + } + + if len(parts) > 0 { + mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", ")) + } + + return s.Tool.ParamMain.Render(ansi.Truncate(mainParam, paramsWidth, "…")) +} + +func earlyState(ctx *RenderContext, header string) (string, bool) { + s := ctx.Styles + message := "" + switch { + case ctx.Result.IsError: + message = renderToolError(ctx) + case ctx.Cancelled: + message = s.Tool.StateCancelled.Render("Canceled.") + case ctx.Result.ToolCallID == "": + message = s.Tool.StateWaiting.Render("Waiting for tool response...") + default: + return "", false + } + + message = s.Tool.BodyPadding.Render(message) + return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true +} + +func renderToolError(ctx *RenderContext) string { + s := ctx.Styles + errTag := s.Tool.ErrorTag.Render("ERROR") + msg := ctx.Result.Content + if msg == "" { + msg = "An error occurred" + } + truncated := ansi.Truncate(msg, ctx.TextWidth()-3-lipgloss.Width(errTag), "…") + return errTag + " " + s.Tool.ErrorMessage.Render(truncated) +} + +func joinHeaderBody(ctx *RenderContext, header, body string) string { + s := ctx.Styles + if body == "" { + return header + } + body = s.Tool.BodyPadding.Render(body) + return lipgloss.JoinVertical(lipgloss.Left, header, "", body) +} + +func renderWithParams(ctx *RenderContext, toolName string, args []string, contentRenderer func() string) string { + header := makeHeader(ctx, toolName, args) + if ctx.IsNested { + return header + } + if res, done := earlyState(ctx, header); done { + return res + } + body := contentRenderer() + return joinHeaderBody(ctx, header, body) +} + +func renderError(ctx *RenderContext, message string) string { + s := ctx.Styles + header := makeHeader(ctx, prettifyToolName(ctx.Call.Name), []string{}) + errorTag := s.Tool.ErrorTag.Render("ERROR") + message = s.Tool.ErrorMessage.Render(ctx.Fit(message, ctx.TextWidth()-3-lipgloss.Width(errorTag))) + return joinHeaderBody(ctx, header, errorTag+" "+message) +} + +func renderPlainContent(ctx *RenderContext, content string) string { + s := ctx.Styles + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\t", " ") + content = strings.TrimSpace(content) + lines := strings.Split(content, "\n") + + width := ctx.TextWidth() - 2 + var out []string + for i, ln := range lines { + if i >= responseContextHeight { + break + } + ln = ansiext.Escape(ln) + ln = " " + ln + if len(ln) > width { + ln = ctx.Fit(ln, width) + } + out = append(out, s.Tool.ContentLine.Width(width).Render(ln)) + } + + if len(lines) > responseContextHeight { + out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight))) + } + + return strings.Join(out, "\n") +} + +func renderMarkdownContent(ctx *RenderContext, content string) string { + s := ctx.Styles + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\t", " ") + content = strings.TrimSpace(content) + + width := ctx.TextWidth() - 2 + width = min(width, 120) + + renderer := common.PlainMarkdownRenderer(width) + rendered, err := renderer.Render(content) + if err != nil { + return renderPlainContent(ctx, content) + } + + lines := strings.Split(rendered, "\n") + + var out []string + for i, ln := range lines { + if i >= responseContextHeight { + break + } + out = append(out, ln) + } + + style := s.Tool.ContentLine + if len(lines) > responseContextHeight { + out = append(out, s.Tool.ContentTruncation. + Width(width-2). + Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight))) + } + + return style.Render(strings.Join(out, "\n")) +} + +func renderCodeContent(ctx *RenderContext, path, content string, offset int) string { + s := ctx.Styles + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\t", " ") + truncated := truncateHeight(content, responseContextHeight) + + lines := strings.Split(truncated, "\n") + for i, ln := range lines { + lines[i] = ansiext.Escape(ln) + } + + bg := s.Tool.ContentCodeBg + highlighted, _ := common.SyntaxHighlight(ctx.Styles, strings.Join(lines, "\n"), path, bg) + lines = strings.Split(highlighted, "\n") + + width := ctx.TextWidth() - 2 + gutterWidth := getDigits(offset+len(lines)) + 1 + + var out []string + for i, ln := range lines { + lineNum := fmt.Sprintf("%*d", gutterWidth, offset+i+1) + gutter := s.Subtle.Render(lineNum + " ") + ln = " " + ln + if lipgloss.Width(gutter+ln) > width { + ln = ctx.Fit(ln, width-lipgloss.Width(gutter)) + } + out = append(out, s.Tool.ContentCodeLine.Width(width).Render(gutter+ln)) + } + + contentLines := strings.Split(content, "\n") + if len(contentLines) > responseContextHeight { + out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))) + } + + return 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 truncateHeight(content string, maxLines int) string { + lines := strings.Split(content, "\n") + if len(lines) <= maxLines { + return content + } + return strings.Join(lines[:maxLines], "\n") +} + +func prettifyToolName(name string) string { + switch name { + case "agent": + return "Agent" + case "bash": + return "Bash" + case "job_output": + return "Job: Output" + case "job_kill": + return "Job: Kill" + case "download": + return "Download" + case "edit": + return "Edit" + case "multiedit": + return "Multi-Edit" + case "fetch": + return "Fetch" + case "agentic_fetch": + return "Agentic Fetch" + case "web_fetch": + return "Fetching" + case "glob": + return "Glob" + case "grep": + return "Grep" + case "ls": + return "List" + case "sourcegraph": + return "Sourcegraph" + case "view": + return "View" + case "write": + return "Write" + case "lsp_references": + return "Find References" + case "lsp_diagnostics": + return "Diagnostics" + default: + parts := strings.Split(name, "_") + for i := range parts { + if len(parts[i]) > 0 { + parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + } + return strings.Join(parts, " ") + } +} + +// Tool-specific renderers + +func renderGeneric(ctx *RenderContext) string { + return renderWithParams(ctx, prettifyToolName(ctx.Call.Name), []string{ctx.Call.Input}, func() string { + return renderPlainContent(ctx, ctx.Result.Content) + }) +} + +func renderView(ctx *RenderContext) string { + var params tools.ViewParams + if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { + return renderError(ctx, "Invalid view parameters") + } + + file := fsext.PrettyPath(params.FilePath) + args := newParamBuilder(). + addMain(file). + addKeyValue("limit", formatNonZero(params.Limit)). + addKeyValue("offset", formatNonZero(params.Offset)). + build() + + return renderWithParams(ctx, "View", args, func() string { + var meta tools.ViewResponseMetadata + if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil { + return renderPlainContent(ctx, ctx.Result.Content) + } + return renderCodeContent(ctx, meta.FilePath, meta.Content, params.Offset) + }) +} + +func renderEdit(ctx *RenderContext) string { + s := ctx.Styles + var params tools.EditParams + var args []string + if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil { + file := fsext.PrettyPath(params.FilePath) + args = newParamBuilder().addMain(file).build() + } + + return renderWithParams(ctx, "Edit", args, func() string { + var meta tools.EditResponseMetadata + if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil { + return renderPlainContent(ctx, ctx.Result.Content) + } + + formatter := common.DiffFormatter(ctx.Styles). + Before(fsext.PrettyPath(params.FilePath), meta.OldContent). + After(fsext.PrettyPath(params.FilePath), meta.NewContent). + Width(ctx.TextWidth() - 2) + if ctx.TextWidth() > 120 { + formatter = formatter.Split() + } + formatted := formatter.String() + if lipgloss.Height(formatted) > responseContextHeight { + contentLines := strings.Split(formatted, "\n") + truncateMessage := s.Tool.DiffTruncation. + Width(ctx.TextWidth() - 2). + Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight)) + formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage + } + return formatted + }) +} + +func renderMultiEdit(ctx *RenderContext) string { + s := ctx.Styles + var params tools.MultiEditParams + var args []string + if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil { + file := fsext.PrettyPath(params.FilePath) + args = newParamBuilder(). + addMain(file). + addKeyValue("edits", fmt.Sprintf("%d", len(params.Edits))). + build() + } + + return renderWithParams(ctx, "Multi-Edit", args, func() string { + var meta tools.MultiEditResponseMetadata + if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil { + return renderPlainContent(ctx, ctx.Result.Content) + } + + formatter := common.DiffFormatter(ctx.Styles). + Before(fsext.PrettyPath(params.FilePath), meta.OldContent). + After(fsext.PrettyPath(params.FilePath), meta.NewContent). + Width(ctx.TextWidth() - 2) + if ctx.TextWidth() > 120 { + formatter = formatter.Split() + } + formatted := formatter.String() + if lipgloss.Height(formatted) > responseContextHeight { + contentLines := strings.Split(formatted, "\n") + truncateMessage := s.Tool.DiffTruncation. + Width(ctx.TextWidth() - 2). + Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight)) + formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage + } + + // Add note about failed edits if any. + if len(meta.EditsFailed) > 0 { + noteTag := s.Tool.NoteTag.Render("NOTE") + noteMsg := s.Tool.NoteMessage.Render( + fmt.Sprintf("%d of %d edits failed", len(meta.EditsFailed), len(params.Edits))) + formatted = formatted + "\n\n" + noteTag + " " + noteMsg + } + + return formatted + }) +} + +func renderWrite(ctx *RenderContext) string { + var params tools.WriteParams + if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { + return renderError(ctx, "Invalid write parameters") + } + + file := fsext.PrettyPath(params.FilePath) + args := newParamBuilder().addMain(file).build() + + return renderWithParams(ctx, "Write", args, func() string { + return renderCodeContent(ctx, params.FilePath, params.Content, 0) + }) +} + +func renderBash(ctx *RenderContext) string { + var params tools.BashParams + if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { + return renderError(ctx, "Invalid bash parameters") + } + + cmd := strings.ReplaceAll(params.Command, "\n", " ") + cmd = strings.ReplaceAll(cmd, "\t", " ") + args := newParamBuilder(). + addMain(cmd). + addFlag("background", params.RunInBackground). + build() + + if ctx.Call.Finished { + var meta tools.BashResponseMetadata + _ = unmarshalParams(ctx.Result.Metadata, &meta) + if meta.Background { + description := cmp.Or(meta.Description, params.Command) + width := ctx.TextWidth() + if ctx.IsNested { + width -= 4 + } + header := makeJobHeader(ctx, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width) + if ctx.IsNested { + return header + } + if res, done := earlyState(ctx, header); done { + return res + } + content := "Command: " + params.Command + "\n" + ctx.Result.Content + body := renderPlainContent(ctx, content) + return joinHeaderBody(ctx, header, body) + } + } + + return renderWithParams(ctx, "Bash", args, func() string { + var meta tools.BashResponseMetadata + if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil { + return renderPlainContent(ctx, ctx.Result.Content) + } + if meta.Output == "" && ctx.Result.Content != tools.BashNoOutput { + meta.Output = ctx.Result.Content + } + + if meta.Output == "" { + return "" + } + return renderPlainContent(ctx, meta.Output) + }) +} + +func makeJobHeader(ctx *RenderContext, action, pid, description string, width int) string { + s := ctx.Styles + icon := s.Tool.JobIconPending.Render(styles.ToolPending) + if ctx.Result.ToolCallID != "" { + if ctx.Result.IsError { + icon = s.Tool.JobIconError.Render(styles.ToolError) + } else { + icon = s.Tool.JobIconSuccess.Render(styles.ToolSuccess) + } + } else if ctx.Cancelled { + icon = s.Muted.Render(styles.ToolPending) + } + + toolName := s.Tool.JobToolName.Render("Bash") + actionPart := s.Tool.JobAction.Render(action) + pidPart := s.Tool.JobPID.Render(pid) + + prefix := fmt.Sprintf("%s %s %s %s ", icon, toolName, actionPart, pidPart) + remainingWidth := width - lipgloss.Width(prefix) + + descDisplay := ansi.Truncate(description, remainingWidth, "…") + descDisplay = s.Tool.JobDescription.Render(descDisplay) + + return prefix + descDisplay +} + +func renderJobOutput(ctx *RenderContext) string { + var params tools.JobOutputParams + if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { + return renderError(ctx, "Invalid job output parameters") + } + + width := ctx.TextWidth() + if ctx.IsNested { + width -= 4 + } + + var meta tools.JobOutputResponseMetadata + _ = unmarshalParams(ctx.Result.Metadata, &meta) + description := cmp.Or(meta.Description, meta.Command) + + header := makeJobHeader(ctx, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width) + if ctx.IsNested { + return header + } + if res, done := earlyState(ctx, header); done { + return res + } + body := renderPlainContent(ctx, ctx.Result.Content) + return joinHeaderBody(ctx, header, body) +} + +func renderJobKill(ctx *RenderContext) string { + var params tools.JobKillParams + if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { + return renderError(ctx, "Invalid job kill parameters") + } + + width := ctx.TextWidth() + if ctx.IsNested { + width -= 4 + } + + var meta tools.JobKillResponseMetadata + _ = unmarshalParams(ctx.Result.Metadata, &meta) + description := cmp.Or(meta.Description, meta.Command) + + header := makeJobHeader(ctx, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width) + if ctx.IsNested { + return header + } + if res, done := earlyState(ctx, header); done { + return res + } + body := renderPlainContent(ctx, ctx.Result.Content) + return joinHeaderBody(ctx, header, body) +} + +func renderSimpleFetch(ctx *RenderContext) string { + var params tools.FetchParams + if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { + return renderError(ctx, "Invalid fetch parameters") + } + + args := newParamBuilder(). + addMain(params.URL). + addKeyValue("format", params.Format). + addKeyValue("timeout", formatNonZero(params.Timeout)). + build() + + return renderWithParams(ctx, "Fetch", args, func() string { + path := "file." + params.Format + return renderCodeContent(ctx, path, ctx.Result.Content, 0) + }) +} + +func renderAgenticFetch(ctx *RenderContext) string { + // TODO: Implement nested tool call rendering with tree. + return renderGeneric(ctx) +} + +func renderWebFetch(ctx *RenderContext) string { + var params tools.WebFetchParams + if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { + return renderError(ctx, "Invalid web fetch parameters") + } + + args := newParamBuilder().addMain(params.URL).build() + + return renderWithParams(ctx, "Fetching", args, func() string { + return renderMarkdownContent(ctx, ctx.Result.Content) + }) +} + +func renderDownload(ctx *RenderContext) string { + var params tools.DownloadParams + if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { + return renderError(ctx, "Invalid download parameters") + } + + args := newParamBuilder(). + addMain(params.URL). + addKeyValue("file", fsext.PrettyPath(params.FilePath)). + addKeyValue("timeout", formatNonZero(params.Timeout)). + build() + + return renderWithParams(ctx, "Download", args, func() string { + return renderPlainContent(ctx, ctx.Result.Content) + }) +} + +func renderGlob(ctx *RenderContext) string { + var params tools.GlobParams + if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { + return renderError(ctx, "Invalid glob parameters") + } + + args := newParamBuilder(). + addMain(params.Pattern). + addKeyValue("path", params.Path). + build() + + return renderWithParams(ctx, "Glob", args, func() string { + return renderPlainContent(ctx, ctx.Result.Content) + }) +} + +func renderGrep(ctx *RenderContext) string { + var params tools.GrepParams + var args []string + if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil { + args = newParamBuilder(). + addMain(params.Pattern). + addKeyValue("path", params.Path). + addKeyValue("include", params.Include). + addFlag("literal", params.LiteralText). + build() + } + + return renderWithParams(ctx, "Grep", args, func() string { + return renderPlainContent(ctx, ctx.Result.Content) + }) +} + +func renderLS(ctx *RenderContext) string { + var params tools.LSParams + path := cmp.Or(params.Path, ".") + args := newParamBuilder().addMain(path).build() + + if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil && params.Path != "" { + args = newParamBuilder().addMain(params.Path).build() + } + + return renderWithParams(ctx, "List", args, func() string { + return renderPlainContent(ctx, ctx.Result.Content) + }) +} + +func renderSourcegraph(ctx *RenderContext) string { + var params tools.SourcegraphParams + if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { + return renderError(ctx, "Invalid sourcegraph parameters") + } + + args := newParamBuilder(). + addMain(params.Query). + addKeyValue("count", formatNonZero(params.Count)). + addKeyValue("context", formatNonZero(params.ContextWindow)). + build() + + return renderWithParams(ctx, "Sourcegraph", args, func() string { + return renderPlainContent(ctx, ctx.Result.Content) + }) +} + +func renderDiagnostics(ctx *RenderContext) string { + args := newParamBuilder().addMain("project").build() + + return renderWithParams(ctx, "Diagnostics", args, func() string { + return renderPlainContent(ctx, ctx.Result.Content) + }) +} + +func renderAgent(ctx *RenderContext) string { + s := ctx.Styles + var params agent.AgentParams + unmarshalParams(ctx.Call.Input, ¶ms) + + prompt := params.Prompt + prompt = strings.ReplaceAll(prompt, "\n", " ") + + header := makeHeader(ctx, "Agent", []string{}) + if res, done := earlyState(ctx, header); ctx.Cancelled && done { + return res + } + taskTag := s.Tool.AgentTaskTag.Render("Task") + remainingWidth := ctx.TextWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2 + remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2) + prompt = s.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) + header = lipgloss.JoinVertical( + lipgloss.Left, + header, + "", + lipgloss.JoinHorizontal( + lipgloss.Left, + taskTag, + " ", + prompt, + ), + ) + childTools := tree.Root(header) + + // TODO: Render nested tool calls when available. + + parts := []string{ + childTools.Enumerator(roundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(), + } + + if ctx.Result.ToolCallID == "" { + // Pending state - would show animation in TUI. + parts = append(parts, "", s.Subtle.Render("Working...")) + } + + header = lipgloss.JoinVertical( + lipgloss.Left, + parts..., + ) + + if ctx.Result.ToolCallID == "" { + return header + } + + body := renderMarkdownContent(ctx, ctx.Result.Content) + return joinHeaderBody(ctx, header, body) +} + +func roundedEnumeratorWithWidth(width int, offset int) func(tree.Children, int) string { + return func(children tree.Children, i int) string { + if children.Length()-1 == i { + return strings.Repeat(" ", offset) + "└" + strings.Repeat("─", width-1) + " " + } + return strings.Repeat(" ", offset) + "├" + strings.Repeat("─", width-1) + " " + } +} From cd88520924a23f2258ec25302a7916760335c12d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 8 Dec 2025 18:04:20 -0500 Subject: [PATCH 030/335] feat(ui): initial lazy-loaded list implementation --- internal/ui/lazylist/item.go | 8 + internal/ui/lazylist/list.go | 440 +++++++++++++ internal/ui/lazylist/list.go.bak | 413 ++++++++++++ internal/ui/list/lazylist.go | 1007 ++++++++++++++++++++++++++++++ internal/ui/list/simplelist.go | 972 ++++++++++++++++++++++++++++ internal/ui/model/chat.go | 28 +- internal/ui/model/items.go | 37 ++ internal/ui/model/ui.go | 6 +- 8 files changed, 2894 insertions(+), 17 deletions(-) create mode 100644 internal/ui/lazylist/item.go create mode 100644 internal/ui/lazylist/list.go create mode 100644 internal/ui/lazylist/list.go.bak create mode 100644 internal/ui/list/lazylist.go create mode 100644 internal/ui/list/simplelist.go diff --git a/internal/ui/lazylist/item.go b/internal/ui/lazylist/item.go new file mode 100644 index 0000000000000000000000000000000000000000..dc39451ea5259a06d68b9dc45e78fdc00a2dac24 --- /dev/null +++ b/internal/ui/lazylist/item.go @@ -0,0 +1,8 @@ +package lazylist + +// Item represents a single item in the lazy-loaded list. +type Item interface { + // Render returns the string representation of the item for the given + // width. + Render(width int) string +} diff --git a/internal/ui/lazylist/list.go b/internal/ui/lazylist/list.go new file mode 100644 index 0000000000000000000000000000000000000000..70862f80b5efd6e3379c01676c1211b0b32221e0 --- /dev/null +++ b/internal/ui/lazylist/list.go @@ -0,0 +1,440 @@ +package lazylist + +import ( + "log/slog" + "strings" +) + +// List represents a list of items that can be lazily rendered. A list is +// always rendered like a chat conversation where items are stacked vertically +// from top to bottom. +type List struct { + // Viewport size + width, height int + + // Items in the list + items []Item + + // Gap between items (0 or less means no gap) + gap int + + // Focus and selection state + focused bool + selectedIdx int // The current selected index -1 means no selection + + // Rendered content and cache + renderedItems map[int]renderedItem + + // offsetIdx is the index of the first visible item in the viewport. + offsetIdx int + // offsetLine is the number of lines of the item at offsetIdx that are + // scrolled out of view (above the viewport). + // It must always be >= 0. + offsetLine int + + // Dirty tracking + dirtyItems map[int]struct{} +} + +// renderedItem holds the rendered content and height of an item. +type renderedItem struct { + content string + height int +} + +// NewList creates a new lazy-loaded list. +func NewList(items ...Item) *List { + l := new(List) + l.items = items + l.renderedItems = make(map[int]renderedItem) + l.dirtyItems = make(map[int]struct{}) + return l +} + +// SetSize sets the size of the list viewport. +func (l *List) SetSize(width, height int) { + if width != l.width { + l.renderedItems = make(map[int]renderedItem) + } + l.width = width + l.height = height + // l.normalizeOffsets() +} + +// SetGap sets the gap between items. +func (l *List) SetGap(gap int) { + l.gap = gap +} + +// Width returns the width of the list viewport. +func (l *List) Width() int { + return l.width +} + +// Height returns the height of the list viewport. +func (l *List) Height() int { + return l.height +} + +// Len returns the number of items in the list. +func (l *List) Len() int { + return len(l.items) +} + +// getItem renders (if needed) and returns the item at the given index. +func (l *List) getItem(idx int) renderedItem { + if idx < 0 || idx >= len(l.items) { + return renderedItem{} + } + + if item, ok := l.renderedItems[idx]; ok { + if _, dirty := l.dirtyItems[idx]; !dirty { + return item + } + } + + item := l.items[idx] + rendered := item.Render(l.width) + height := countLines(rendered) + // slog.Info("Rendered item", "idx", idx, "height", height) + + ri := renderedItem{ + content: rendered, + height: height, + } + + l.renderedItems[idx] = ri + delete(l.dirtyItems, idx) + + return ri +} + +// ScrollToIndex scrolls the list to the given item index. +func (l *List) ScrollToIndex(index int) { + if index < 0 { + index = 0 + } + if index >= len(l.items) { + index = len(l.items) - 1 + } + l.offsetIdx = index + l.offsetLine = 0 +} + +// ScrollBy scrolls the list by the given number of lines. +func (l *List) ScrollBy(lines int) { + if len(l.items) == 0 || lines == 0 { + return + } + + if lines > 0 { + // Scroll down + // Calculate from the bottom how many lines needed to anchor the last + // item to the bottom + var totalLines int + var lastItemIdx int // the last item that can be partially visible + for i := len(l.items) - 1; i >= 0; i-- { + item := l.getItem(i) + totalLines += item.height + if l.gap > 0 && i < len(l.items)-1 { + totalLines += l.gap + } + if totalLines >= l.height { + lastItemIdx = i + break + } + } + + // Now scroll down by lines + var item renderedItem + l.offsetLine += lines + for { + item = l.getItem(l.offsetIdx) + totalHeight := item.height + if l.gap > 0 { + totalHeight += l.gap + } + + if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight { + // Valid offset + break + } + + // Move to next item + l.offsetLine -= totalHeight + l.offsetIdx++ + } + + if l.offsetLine >= item.height { + l.offsetLine = item.height - 1 + } + } else if lines < 0 { + // Scroll up + l.offsetLine += lines + for l.offsetLine < 0 { + if l.offsetIdx == 0 { + // Reached the top of the list + l.offsetLine = 0 + break + } + + // Move to previous item + l.offsetIdx-- + item := l.getItem(l.offsetIdx) + totalHeight := item.height + if l.gap > 0 { + totalHeight += l.gap + } + l.offsetLine += totalHeight + } + + item := l.getItem(l.offsetIdx) + if l.offsetLine >= item.height { + l.offsetLine = item.height - 1 + } + } +} + +// findVisibleItems finds the range of items that are visible in the viewport. +// This is used for checking if selected item is in view. +func (l *List) findVisibleItems() (startIdx, endIdx int) { + if len(l.items) == 0 { + return 0, 0 + } + + startIdx = l.offsetIdx + currentIdx := startIdx + visibleHeight := -l.offsetLine + + for currentIdx < len(l.items) { + item := l.getItem(currentIdx) + visibleHeight += item.height + if l.gap > 0 { + visibleHeight += l.gap + } + + if visibleHeight >= l.height { + break + } + currentIdx++ + } + + endIdx = currentIdx + if endIdx >= len(l.items) { + endIdx = len(l.items) - 1 + } + + return startIdx, endIdx +} + +// Render renders the list and returns the visible lines. +func (l *List) Render() string { + if len(l.items) == 0 { + return "" + } + + slog.Info("Render", "offsetIdx", l.offsetIdx, "offsetLine", l.offsetLine, "width", l.width, "height", l.height) + + var lines []string + currentIdx := l.offsetIdx + currentOffset := l.offsetLine + + linesNeeded := l.height + + for linesNeeded > 0 && currentIdx < len(l.items) { + item := l.getItem(currentIdx) + itemLines := strings.Split(item.content, "\n") + itemHeight := len(itemLines) + + if currentOffset < itemHeight { + // Add visible content lines + lines = append(lines, itemLines[currentOffset:]...) + + // Add gap if this is not the absolute last visual element (conceptually gaps are between items) + // But in the loop we can just add it and trim later + if l.gap > 0 { + for i := 0; i < l.gap; i++ { + lines = append(lines, "") + } + } + } else { + // offsetLine starts in the gap + gapOffset := currentOffset - itemHeight + gapRemaining := l.gap - gapOffset + if gapRemaining > 0 { + for i := 0; i < gapRemaining; i++ { + lines = append(lines, "") + } + } + } + + linesNeeded = l.height - len(lines) + currentIdx++ + currentOffset = 0 // Reset offset for subsequent items + } + + if len(lines) > l.height { + lines = lines[:l.height] + } + + return strings.Join(lines, "\n") +} + +// PrependItems prepends items to the list. +func (l *List) PrependItems(items ...Item) { + l.items = append(items, l.items...) + + // Shift cache + newCache := make(map[int]renderedItem) + for idx, val := range l.renderedItems { + newCache[idx+len(items)] = val + } + l.renderedItems = newCache + + // Shift dirty items + newDirty := make(map[int]struct{}) + for idx := range l.dirtyItems { + newDirty[idx+len(items)] = struct{}{} + } + l.dirtyItems = newDirty + + // Keep view position relative to the content that was visible + l.offsetIdx += len(items) + + // Update selection index if valid + if l.selectedIdx != -1 { + l.selectedIdx += len(items) + } +} + +// AppendItems appends items to the list. +func (l *List) AppendItems(items ...Item) { + l.items = append(l.items, items...) +} + +// Focus sets the focus state of the list. +func (l *List) Focus() { + l.focused = true +} + +// Blur removes the focus state from the list. +func (l *List) Blur() { + l.focused = false +} + +// ScrollToTop scrolls the list to the top. +func (l *List) ScrollToTop() { + l.offsetIdx = 0 + l.offsetLine = 0 +} + +// ScrollToBottom scrolls the list to the bottom. +func (l *List) ScrollToBottom() { + if len(l.items) == 0 { + return + } + + // Scroll to the last item + var totalHeight int + var i int + for i = len(l.items) - 1; i >= 0; i-- { + item := l.getItem(i) + totalHeight += item.height + if l.gap > 0 && i < len(l.items)-1 { + totalHeight += l.gap + } + if totalHeight >= l.height { + l.offsetIdx = i + l.offsetLine = totalHeight - l.height + break + } + } + if i < 0 { + // All items fit in the viewport + l.offsetIdx = 0 + l.offsetLine = 0 + } +} + +// ScrollToSelected scrolls the list to the selected item. +func (l *List) ScrollToSelected() { + // TODO: Implement me +} + +// SelectedItemInView returns whether the selected item is currently in view. +func (l *List) SelectedItemInView() bool { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return false + } + startIdx, endIdx := l.findVisibleItems() + return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx +} + +// SetSelected sets the selected item index in the list. +func (l *List) SetSelected(index int) { + if index < 0 || index >= len(l.items) { + l.selectedIdx = -1 + } else { + l.selectedIdx = index + } +} + +// SelectPrev selects the previous item in the list. +func (l *List) SelectPrev() { + if l.selectedIdx > 0 { + l.selectedIdx-- + } +} + +// SelectNext selects the next item in the list. +func (l *List) SelectNext() { + if l.selectedIdx < len(l.items)-1 { + l.selectedIdx++ + } +} + +// SelectFirst selects the first item in the list. +func (l *List) SelectFirst() { + if len(l.items) > 0 { + l.selectedIdx = 0 + } +} + +// SelectLast selects the last item in the list. +func (l *List) SelectLast() { + if len(l.items) > 0 { + l.selectedIdx = len(l.items) - 1 + } +} + +// SelectFirstInView selects the first item currently in view. +func (l *List) SelectFirstInView() { + startIdx, _ := l.findVisibleItems() + l.selectedIdx = startIdx +} + +// SelectLastInView selects the last item currently in view. +func (l *List) SelectLastInView() { + _, endIdx := l.findVisibleItems() + l.selectedIdx = endIdx +} + +// HandleMouseDown handles mouse down events at the given line in the viewport. +func (l *List) HandleMouseDown(x, y int) { +} + +// HandleMouseUp handles mouse up events at the given line in the viewport. +func (l *List) HandleMouseUp(x, y int) { +} + +// HandleMouseDrag handles mouse drag events at the given line in the viewport. +func (l *List) HandleMouseDrag(x, y int) { +} + +// countLines counts the number of lines in a string. +func countLines(s string) int { + if s == "" { + return 0 + } + return strings.Count(s, "\n") + 1 +} diff --git a/internal/ui/lazylist/list.go.bak b/internal/ui/lazylist/list.go.bak new file mode 100644 index 0000000000000000000000000000000000000000..9aec6442e1ace230cc660b8d1cca6bf9b685c845 --- /dev/null +++ b/internal/ui/lazylist/list.go.bak @@ -0,0 +1,413 @@ +package lazylist + +import ( + "log/slog" + "strings" +) + +// List represents a list of items that can be lazily rendered. A list is +// always rendered like a chat conversation where items are stacked vertically +// from top to bottom. +type List struct { + // Viewport size + width, height int + + // Items in the list + items []Item + + // Gap between items (0 or less means no gap) + gap int + + // Focus and selection state + focused bool + selectedIdx int // The current selected index -1 means no selection + + // Item positioning. If a position exists in the map, it means the item has + // been rendered and measured. + itemPositions map[int]itemPosition + + // Rendered content and cache + lines []string + renderedItems map[int]renderedItem + offsetIdx int // Index of the first visible item in the viewport + offsetLine int // The offset line from the start of the offsetIdx item (can be negative) + + // Dirty tracking + dirtyItems map[int]struct{} +} + +// renderedItem holds the rendered content and height of an item. +type renderedItem struct { + content string + height int +} + +// itemPosition holds the start and end line of an item in the list. +type itemPosition struct { + startLine int + endLine int +} + +// Height returns the height of item based on its start and end lines. +func (ip itemPosition) Height() int { + return ip.endLine - ip.startLine +} + +// NewList creates a new lazy-loaded list. +func NewList(items ...Item) *List { + l := new(List) + l.items = items + l.itemPositions = make(map[int]itemPosition) + l.renderedItems = make(map[int]renderedItem) + l.dirtyItems = make(map[int]struct{}) + return l +} + +// SetSize sets the size of the list viewport. +func (l *List) SetSize(width, height int) { + if width != l.width { + // Mark all rendered items as dirty if width changes because their + // layout may change. + for idx := range l.itemPositions { + l.dirtyItems[idx] = struct{}{} + } + } + l.width = width + l.height = height +} + +// SetGap sets the gap between items. +func (l *List) SetGap(gap int) { + l.gap = gap +} + +// Width returns the width of the list viewport. +func (l *List) Width() int { + return l.width +} + +// Height returns the height of the list viewport. +func (l *List) Height() int { + return l.height +} + +// Len returns the number of items in the list. +func (l *List) Len() int { + return len(l.items) +} + +// renderItem renders the item at the given index and updates its cache and +// position. +func (l *List) renderItem(idx int) { + if idx < 0 || idx >= len(l.items) { + return + } + + item := l.items[idx] + rendered := item.Render(l.width) + height := countLines(rendered) + + l.renderedItems[idx] = renderedItem{ + content: rendered, + height: height, + } + + // Calculate item position + var startLine int + if idx == 0 { + startLine = 0 + } else { + prevPos, ok := l.itemPositions[idx-1] + if !ok { + l.renderItem(idx - 1) + prevPos = l.itemPositions[idx-1] + } + startLine = prevPos.endLine + if l.gap > 0 { + startLine += l.gap + } + } + endLine := startLine + height + + l.itemPositions[idx] = itemPosition{ + startLine: startLine, + endLine: endLine, + } +} + +// ScrollToIndex scrolls the list to the given item index. +func (l *List) ScrollToIndex(index int) { + if index < 0 || index >= len(l.items) { + return + } + l.offsetIdx = index + l.offsetLine = 0 +} + +// ScrollBy scrolls the list by the given number of lines. +func (l *List) ScrollBy(lines int) { + l.offsetLine += lines + if l.offsetIdx <= 0 && l.offsetLine < 0 { + l.offsetIdx = 0 + l.offsetLine = 0 + return + } + + // Adjust offset index and line if needed + for l.offsetLine < 0 && l.offsetIdx > 0 { + // Move up to previous item + l.offsetIdx-- + prevPos, ok := l.itemPositions[l.offsetIdx] + if !ok { + l.renderItem(l.offsetIdx) + prevPos = l.itemPositions[l.offsetIdx] + } + l.offsetLine += prevPos.Height() + if l.gap > 0 { + l.offsetLine += l.gap + } + } + + for { + currentPos, ok := l.itemPositions[l.offsetIdx] + if !ok { + l.renderItem(l.offsetIdx) + currentPos = l.itemPositions[l.offsetIdx] + } + if l.offsetLine >= currentPos.Height() { + // Move down to next item + l.offsetLine -= currentPos.Height() + if l.gap > 0 { + l.offsetLine -= l.gap + } + l.offsetIdx++ + if l.offsetIdx >= len(l.items) { + l.offsetIdx = len(l.items) - 1 + l.offsetLine = currentPos.Height() - 1 + break + } + } else { + break + } + } +} + +// findVisibleItems finds the range of items that are visible in the viewport. +func (l *List) findVisibleItems() (startIdx, endIdx int) { + startIdx = l.offsetIdx + endIdx = startIdx + 1 + + // Render items until we fill the viewport + visibleHeight := -l.offsetLine + for endIdx < len(l.items) { + pos, ok := l.itemPositions[endIdx-1] + if !ok { + l.renderItem(endIdx - 1) + pos = l.itemPositions[endIdx-1] + } + visibleHeight += pos.Height() + if endIdx-1 < len(l.items)-1 && l.gap > 0 { + visibleHeight += l.gap + } + if visibleHeight >= l.height { + break + } + endIdx++ + } + + if endIdx > len(l.items)-1 { + endIdx = len(l.items) - 1 + } + + return startIdx, endIdx +} + +// renderLines renders the items between startIdx and endIdx into lines. +func (l *List) renderLines(startIdx, endIdx int) []string { + var lines []string + for idx := startIdx; idx < endIdx+1; idx++ { + rendered, ok := l.renderedItems[idx] + if !ok { + l.renderItem(idx) + rendered = l.renderedItems[idx] + } + itemLines := strings.Split(rendered.content, "\n") + lines = append(lines, itemLines...) + if l.gap > 0 && idx < endIdx { + for i := 0; i < l.gap; i++ { + lines = append(lines, "") + } + } + } + return lines +} + +// Render renders the list and returns the visible lines. +func (l *List) Render() string { + viewStartIdx, viewEndIdx := l.findVisibleItems() + slog.Info("Render", "viewStartIdx", viewStartIdx, "viewEndIdx", viewEndIdx, "offsetIdx", l.offsetIdx, "offsetLine", l.offsetLine) + + for idx := range l.dirtyItems { + if idx >= viewStartIdx && idx <= viewEndIdx { + l.renderItem(idx) + delete(l.dirtyItems, idx) + } + } + + lines := l.renderLines(viewStartIdx, viewEndIdx) + for len(lines) < l.height { + viewStartIdx-- + if viewStartIdx <= 0 { + break + } + + lines = l.renderLines(viewStartIdx, viewEndIdx) + } + + if len(lines) > l.height { + lines = lines[:l.height] + } + + return strings.Join(lines, "\n") +} + +// PrependItems prepends items to the list. +func (l *List) PrependItems(items ...Item) { + l.items = append(items, l.items...) + // Shift existing item positions + newItemPositions := make(map[int]itemPosition) + for idx, pos := range l.itemPositions { + newItemPositions[idx+len(items)] = pos + } + l.itemPositions = newItemPositions + + // Mark all items as dirty + for idx := range l.items { + l.dirtyItems[idx] = struct{}{} + } + + // Adjust offset index + l.offsetIdx += len(items) +} + +// AppendItems appends items to the list. +func (l *List) AppendItems(items ...Item) { + l.items = append(l.items, items...) + for idx := len(l.items) - len(items); idx < len(l.items); idx++ { + l.dirtyItems[idx] = struct{}{} + } +} + +// Focus sets the focus state of the list. +func (l *List) Focus() { + l.focused = true +} + +// Blur removes the focus state from the list. +func (l *List) Blur() { + l.focused = false +} + +// ScrollToTop scrolls the list to the top. +func (l *List) ScrollToTop() { + l.offsetIdx = 0 + l.offsetLine = 0 +} + +// ScrollToBottom scrolls the list to the bottom. +func (l *List) ScrollToBottom() { + l.offsetIdx = len(l.items) - 1 + pos, ok := l.itemPositions[l.offsetIdx] + if !ok { + l.renderItem(l.offsetIdx) + pos = l.itemPositions[l.offsetIdx] + } + l.offsetLine = l.height - pos.Height() +} + +// ScrollToSelected scrolls the list to the selected item. +func (l *List) ScrollToSelected() { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return + } + l.offsetIdx = l.selectedIdx + l.offsetLine = 0 +} + +// SelectedItemInView returns whether the selected item is currently in view. +func (l *List) SelectedItemInView() bool { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return false + } + startIdx, endIdx := l.findVisibleItems() + return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx +} + +// SetSelected sets the selected item index in the list. +func (l *List) SetSelected(index int) { + if index < 0 || index >= len(l.items) { + l.selectedIdx = -1 + } else { + l.selectedIdx = index + } +} + +// SelectPrev selects the previous item in the list. +func (l *List) SelectPrev() { + if l.selectedIdx > 0 { + l.selectedIdx-- + } +} + +// SelectNext selects the next item in the list. +func (l *List) SelectNext() { + if l.selectedIdx < len(l.items)-1 { + l.selectedIdx++ + } +} + +// SelectFirst selects the first item in the list. +func (l *List) SelectFirst() { + if len(l.items) > 0 { + l.selectedIdx = 0 + } +} + +// SelectLast selects the last item in the list. +func (l *List) SelectLast() { + if len(l.items) > 0 { + l.selectedIdx = len(l.items) - 1 + } +} + +// SelectFirstInView selects the first item currently in view. +func (l *List) SelectFirstInView() { + startIdx, _ := l.findVisibleItems() + l.selectedIdx = startIdx +} + +// SelectLastInView selects the last item currently in view. +func (l *List) SelectLastInView() { + _, endIdx := l.findVisibleItems() + l.selectedIdx = endIdx +} + +// HandleMouseDown handles mouse down events at the given line in the viewport. +func (l *List) HandleMouseDown(x, y int) { +} + +// HandleMouseUp handles mouse up events at the given line in the viewport. +func (l *List) HandleMouseUp(x, y int) { +} + +// HandleMouseDrag handles mouse drag events at the given line in the viewport. +func (l *List) HandleMouseDrag(x, y int) { +} + +// countLines counts the number of lines in a string. +func countLines(s string) int { + if s == "" { + return 0 + } + return strings.Count(s, "\n") + 1 +} diff --git a/internal/ui/list/lazylist.go b/internal/ui/list/lazylist.go new file mode 100644 index 0000000000000000000000000000000000000000..58ce3bf3eff9869f220af3574fc920865090e172 --- /dev/null +++ b/internal/ui/list/lazylist.go @@ -0,0 +1,1007 @@ +package list + +import ( + "strings" + + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/ultraviolet/screen" +) + +// LazyList is a virtual scrolling list that only renders visible items. +// It uses height estimates to avoid expensive renders during initial layout. +type LazyList struct { + // Configuration + width, height int + + // Data + items []Item + + // Focus & Selection + focused bool + selectedIdx int // Currently selected item index (-1 if none) + + // Item positioning - tracks measured and estimated positions + itemHeights []itemHeight + totalHeight int // Sum of all item heights (measured or estimated) + + // Viewport state + offset int // Scroll offset in lines from top + + // Rendered items cache - only visible items are rendered + renderedCache map[int]*renderedItemCache + + // Virtual scrolling configuration + defaultEstimate int // Default height estimate for unmeasured items + overscan int // Number of items to render outside viewport for smooth scrolling + + // Dirty tracking + needsLayout bool + dirtyItems map[int]bool + dirtyViewport bool // True if we need to re-render viewport + + // Mouse state + mouseDown bool + mouseDownItem int + mouseDownX int + mouseDownY int + mouseDragItem int + mouseDragX int + mouseDragY int +} + +// itemHeight tracks the height of an item - either measured or estimated. +type itemHeight struct { + height int + measured bool // true if height is actual measurement, false if estimate +} + +// renderedItemCache stores a rendered item's buffer. +type renderedItemCache struct { + buffer *uv.ScreenBuffer + height int // Actual measured height after rendering +} + +// NewLazyList creates a new lazy-rendering list. +func NewLazyList(items ...Item) *LazyList { + l := &LazyList{ + items: items, + itemHeights: make([]itemHeight, len(items)), + renderedCache: make(map[int]*renderedItemCache), + dirtyItems: make(map[int]bool), + selectedIdx: -1, + mouseDownItem: -1, + mouseDragItem: -1, + defaultEstimate: 10, // Conservative estimate: 5 lines per item + overscan: 5, // Render 3 items above/below viewport + needsLayout: true, + dirtyViewport: true, + } + + // Initialize all items with estimated heights + for i := range l.items { + l.itemHeights[i] = itemHeight{ + height: l.defaultEstimate, + measured: false, + } + } + l.calculateTotalHeight() + + return l +} + +// calculateTotalHeight sums all item heights (measured or estimated). +func (l *LazyList) calculateTotalHeight() { + l.totalHeight = 0 + for _, h := range l.itemHeights { + l.totalHeight += h.height + } +} + +// getItemPosition returns the Y position where an item starts. +func (l *LazyList) getItemPosition(idx int) int { + pos := 0 + for i := 0; i < idx && i < len(l.itemHeights); i++ { + pos += l.itemHeights[i].height + } + return pos +} + +// findVisibleItems returns the range of items that are visible or near the viewport. +func (l *LazyList) findVisibleItems() (firstIdx, lastIdx int) { + if len(l.items) == 0 { + return 0, 0 + } + + viewportStart := l.offset + viewportEnd := l.offset + l.height + + // Find first visible item + firstIdx = -1 + pos := 0 + for i := 0; i < len(l.items); i++ { + itemEnd := pos + l.itemHeights[i].height + if itemEnd > viewportStart { + firstIdx = i + break + } + pos = itemEnd + } + + // Apply overscan above + firstIdx = max(0, firstIdx-l.overscan) + + // Find last visible item + lastIdx = firstIdx + pos = l.getItemPosition(firstIdx) + for i := firstIdx; i < len(l.items); i++ { + if pos >= viewportEnd { + break + } + pos += l.itemHeights[i].height + lastIdx = i + } + + // Apply overscan below + lastIdx = min(len(l.items)-1, lastIdx+l.overscan) + + return firstIdx, lastIdx +} + +// renderItem renders a single item and caches it. +// Returns the actual measured height. +func (l *LazyList) renderItem(idx int) int { + if idx < 0 || idx >= len(l.items) { + return 0 + } + + item := l.items[idx] + + // Measure actual height + actualHeight := item.Height(l.width) + + // Create buffer and render + buf := uv.NewScreenBuffer(l.width, actualHeight) + area := uv.Rect(0, 0, l.width, actualHeight) + item.Draw(&buf, area) + + // Cache rendered item + l.renderedCache[idx] = &renderedItemCache{ + buffer: &buf, + height: actualHeight, + } + + // Update height if it was estimated or changed + if !l.itemHeights[idx].measured || l.itemHeights[idx].height != actualHeight { + oldHeight := l.itemHeights[idx].height + l.itemHeights[idx] = itemHeight{ + height: actualHeight, + measured: true, + } + + // Adjust total height + l.totalHeight += actualHeight - oldHeight + } + + return actualHeight +} + +// Draw implements uv.Drawable. +func (l *LazyList) Draw(scr uv.Screen, area uv.Rectangle) { + if area.Dx() <= 0 || area.Dy() <= 0 { + return + } + + widthChanged := l.width != area.Dx() + heightChanged := l.height != area.Dy() + + l.width = area.Dx() + l.height = area.Dy() + + // Width changes invalidate all cached renders + if widthChanged { + l.renderedCache = make(map[int]*renderedItemCache) + // Mark all heights as needing remeasurement + for i := range l.itemHeights { + l.itemHeights[i].measured = false + l.itemHeights[i].height = l.defaultEstimate + } + l.calculateTotalHeight() + l.needsLayout = true + l.dirtyViewport = true + } + + if heightChanged { + l.clampOffset() + l.dirtyViewport = true + } + + if len(l.items) == 0 { + screen.ClearArea(scr, area) + return + } + + // Find visible items based on current estimates + firstIdx, lastIdx := l.findVisibleItems() + + // Track the first visible item's position to maintain stability + // Only stabilize if we're not at the top boundary + stabilizeIdx := -1 + stabilizeY := 0 + if l.offset > 0 { + for i := firstIdx; i <= lastIdx; i++ { + itemPos := l.getItemPosition(i) + if itemPos >= l.offset { + stabilizeIdx = i + stabilizeY = itemPos + break + } + } + } + + // Track if any heights changed during rendering + heightsChanged := false + + // Render visible items that aren't cached (measurement pass) + for i := firstIdx; i <= lastIdx; i++ { + if _, cached := l.renderedCache[i]; !cached { + oldHeight := l.itemHeights[i].height + l.renderItem(i) + if l.itemHeights[i].height != oldHeight { + heightsChanged = true + } + } else if l.dirtyItems[i] { + // Re-render dirty items + oldHeight := l.itemHeights[i].height + l.renderItem(i) + delete(l.dirtyItems, i) + if l.itemHeights[i].height != oldHeight { + heightsChanged = true + } + } + } + + // If heights changed, adjust offset to keep stabilization point stable + if heightsChanged && stabilizeIdx >= 0 { + newStabilizeY := l.getItemPosition(stabilizeIdx) + offsetDelta := newStabilizeY - stabilizeY + + // Adjust offset to maintain visual stability + l.offset += offsetDelta + l.clampOffset() + + // Re-find visible items with adjusted positions + firstIdx, lastIdx = l.findVisibleItems() + + // Render any newly visible items after position adjustments + for i := firstIdx; i <= lastIdx; i++ { + if _, cached := l.renderedCache[i]; !cached { + l.renderItem(i) + } + } + } + + // Clear old cache entries outside visible range + if len(l.renderedCache) > (lastIdx-firstIdx+1)*2 { + l.pruneCache(firstIdx, lastIdx) + } + + // Composite visible items into viewport with stable positions + l.drawViewport(scr, area, firstIdx, lastIdx) + + l.dirtyViewport = false + l.needsLayout = false +} + +// drawViewport composites visible items into the screen. +func (l *LazyList) drawViewport(scr uv.Screen, area uv.Rectangle, firstIdx, lastIdx int) { + screen.ClearArea(scr, area) + + itemStartY := l.getItemPosition(firstIdx) + + for i := firstIdx; i <= lastIdx; i++ { + cached, ok := l.renderedCache[i] + if !ok { + continue + } + + // Calculate where this item appears in viewport + itemY := itemStartY - l.offset + itemHeight := cached.height + + // Skip if entirely above viewport + if itemY+itemHeight < 0 { + itemStartY += itemHeight + continue + } + + // Stop if entirely below viewport + if itemY >= l.height { + break + } + + // Calculate visible portion of item + srcStartY := 0 + dstStartY := itemY + + if itemY < 0 { + // Item starts above viewport + srcStartY = -itemY + dstStartY = 0 + } + + srcEndY := srcStartY + (l.height - dstStartY) + if srcEndY > itemHeight { + srcEndY = itemHeight + } + + // Copy visible lines from item buffer to screen + buf := cached.buffer.Buffer + destY := area.Min.Y + dstStartY + + for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ { + if srcY >= buf.Height() { + break + } + + line := buf.Line(srcY) + destX := area.Min.X + + for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ { + cell := line.At(x) + scr.SetCell(destX, destY, cell) + destX++ + } + destY++ + } + + itemStartY += itemHeight + } +} + +// pruneCache removes cached items outside the visible range. +func (l *LazyList) pruneCache(firstIdx, lastIdx int) { + keepStart := max(0, firstIdx-l.overscan*2) + keepEnd := min(len(l.items)-1, lastIdx+l.overscan*2) + + for idx := range l.renderedCache { + if idx < keepStart || idx > keepEnd { + delete(l.renderedCache, idx) + } + } +} + +// clampOffset ensures scroll offset stays within valid bounds. +func (l *LazyList) clampOffset() { + maxOffset := l.totalHeight - l.height + if maxOffset < 0 { + maxOffset = 0 + } + + if l.offset > maxOffset { + l.offset = maxOffset + } + if l.offset < 0 { + l.offset = 0 + } +} + +// SetItems replaces all items in the list. +func (l *LazyList) SetItems(items []Item) { + l.items = items + l.itemHeights = make([]itemHeight, len(items)) + l.renderedCache = make(map[int]*renderedItemCache) + l.dirtyItems = make(map[int]bool) + + // Initialize with estimates + for i := range l.items { + l.itemHeights[i] = itemHeight{ + height: l.defaultEstimate, + measured: false, + } + } + l.calculateTotalHeight() + l.needsLayout = true + l.dirtyViewport = true +} + +// AppendItem adds an item to the end of the list. +func (l *LazyList) AppendItem(item Item) { + l.items = append(l.items, item) + l.itemHeights = append(l.itemHeights, itemHeight{ + height: l.defaultEstimate, + measured: false, + }) + l.totalHeight += l.defaultEstimate + l.dirtyViewport = true +} + +// PrependItem adds an item to the beginning of the list. +func (l *LazyList) PrependItem(item Item) { + l.items = append([]Item{item}, l.items...) + l.itemHeights = append([]itemHeight{{ + height: l.defaultEstimate, + measured: false, + }}, l.itemHeights...) + + // Shift cache indices + newCache := make(map[int]*renderedItemCache) + for idx, cached := range l.renderedCache { + newCache[idx+1] = cached + } + l.renderedCache = newCache + + l.totalHeight += l.defaultEstimate + l.offset += l.defaultEstimate // Maintain scroll position + l.dirtyViewport = true +} + +// UpdateItem replaces an item at the given index. +func (l *LazyList) UpdateItem(idx int, item Item) { + if idx < 0 || idx >= len(l.items) { + return + } + + l.items[idx] = item + delete(l.renderedCache, idx) + l.dirtyItems[idx] = true + // Keep height estimate - will remeasure on next render + l.dirtyViewport = true +} + +// ScrollBy scrolls by the given number of lines. +func (l *LazyList) ScrollBy(delta int) { + l.offset += delta + l.clampOffset() + l.dirtyViewport = true +} + +// ScrollToBottom scrolls to the end of the list. +func (l *LazyList) ScrollToBottom() { + l.offset = l.totalHeight - l.height + l.clampOffset() + l.dirtyViewport = true +} + +// ScrollToTop scrolls to the beginning of the list. +func (l *LazyList) ScrollToTop() { + l.offset = 0 + l.dirtyViewport = true +} + +// Len returns the number of items in the list. +func (l *LazyList) Len() int { + return len(l.items) +} + +// Focus sets the list as focused. +func (l *LazyList) Focus() { + l.focused = true + l.focusSelectedItem() + l.dirtyViewport = true +} + +// Blur removes focus from the list. +func (l *LazyList) Blur() { + l.focused = false + l.blurSelectedItem() + l.dirtyViewport = true +} + +// focusSelectedItem focuses the currently selected item if it's focusable. +func (l *LazyList) focusSelectedItem() { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return + } + + item := l.items[l.selectedIdx] + if f, ok := item.(Focusable); ok { + f.Focus() + delete(l.renderedCache, l.selectedIdx) + l.dirtyItems[l.selectedIdx] = true + } +} + +// blurSelectedItem blurs the currently selected item if it's focusable. +func (l *LazyList) blurSelectedItem() { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return + } + + item := l.items[l.selectedIdx] + if f, ok := item.(Focusable); ok { + f.Blur() + delete(l.renderedCache, l.selectedIdx) + l.dirtyItems[l.selectedIdx] = true + } +} + +// IsFocused returns whether the list is focused. +func (l *LazyList) IsFocused() bool { + return l.focused +} + +// Width returns the current viewport width. +func (l *LazyList) Width() int { + return l.width +} + +// Height returns the current viewport height. +func (l *LazyList) Height() int { + return l.height +} + +// SetSize sets the viewport size explicitly. +// This is useful when you want to pre-configure the list size before drawing. +func (l *LazyList) SetSize(width, height int) { + widthChanged := l.width != width + heightChanged := l.height != height + + l.width = width + l.height = height + + // Width changes invalidate all cached renders + if widthChanged && width > 0 { + l.renderedCache = make(map[int]*renderedItemCache) + // Mark all heights as needing remeasurement + for i := range l.itemHeights { + l.itemHeights[i].measured = false + l.itemHeights[i].height = l.defaultEstimate + } + l.calculateTotalHeight() + l.needsLayout = true + l.dirtyViewport = true + } + + if heightChanged && height > 0 { + l.clampOffset() + l.dirtyViewport = true + } + + // After cache invalidation, scroll to selected item or bottom + if widthChanged || heightChanged { + if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) { + // Scroll to selected item + l.ScrollToSelected() + } else if len(l.items) > 0 { + // No selection - scroll to bottom + l.ScrollToBottom() + } + } +} + +// Selection methods + +// Selected returns the currently selected item index (-1 if none). +func (l *LazyList) Selected() int { + return l.selectedIdx +} + +// SetSelected sets the selected item by index. +func (l *LazyList) SetSelected(idx int) { + if idx < -1 || idx >= len(l.items) { + return + } + + if l.selectedIdx != idx { + prevIdx := l.selectedIdx + l.selectedIdx = idx + l.dirtyViewport = true + + // Update focus states if list is focused. + if l.focused { + // Blur previously selected item. + if prevIdx >= 0 && prevIdx < len(l.items) { + if f, ok := l.items[prevIdx].(Focusable); ok { + f.Blur() + delete(l.renderedCache, prevIdx) + l.dirtyItems[prevIdx] = true + } + } + + // Focus newly selected item. + if idx >= 0 && idx < len(l.items) { + if f, ok := l.items[idx].(Focusable); ok { + f.Focus() + delete(l.renderedCache, idx) + l.dirtyItems[idx] = true + } + } + } + } +} + +// SelectPrev selects the previous item. +func (l *LazyList) SelectPrev() { + if len(l.items) == 0 { + return + } + + if l.selectedIdx <= 0 { + l.selectedIdx = 0 + } else { + l.selectedIdx-- + } + + l.dirtyViewport = true +} + +// SelectNext selects the next item. +func (l *LazyList) SelectNext() { + if len(l.items) == 0 { + return + } + + if l.selectedIdx < 0 { + l.selectedIdx = 0 + } else if l.selectedIdx < len(l.items)-1 { + l.selectedIdx++ + } + + l.dirtyViewport = true +} + +// SelectFirst selects the first item. +func (l *LazyList) SelectFirst() { + if len(l.items) > 0 { + l.selectedIdx = 0 + l.dirtyViewport = true + } +} + +// SelectLast selects the last item. +func (l *LazyList) SelectLast() { + if len(l.items) > 0 { + l.selectedIdx = len(l.items) - 1 + l.dirtyViewport = true + } +} + +// SelectFirstInView selects the first visible item in the viewport. +func (l *LazyList) SelectFirstInView() { + if len(l.items) == 0 { + return + } + + firstIdx, _ := l.findVisibleItems() + l.selectedIdx = firstIdx + l.dirtyViewport = true +} + +// SelectLastInView selects the last visible item in the viewport. +func (l *LazyList) SelectLastInView() { + if len(l.items) == 0 { + return + } + + _, lastIdx := l.findVisibleItems() + l.selectedIdx = lastIdx + l.dirtyViewport = true +} + +// SelectedItemInView returns whether the selected item is visible in the viewport. +func (l *LazyList) SelectedItemInView() bool { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return false + } + + firstIdx, lastIdx := l.findVisibleItems() + return l.selectedIdx >= firstIdx && l.selectedIdx <= lastIdx +} + +// ScrollToSelected scrolls the viewport to ensure the selected item is visible. +func (l *LazyList) ScrollToSelected() { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return + } + + // Get selected item position + itemY := l.getItemPosition(l.selectedIdx) + itemHeight := l.itemHeights[l.selectedIdx].height + + // Check if item is above viewport + if itemY < l.offset { + l.offset = itemY + l.dirtyViewport = true + return + } + + // Check if item is below viewport + itemBottom := itemY + itemHeight + viewportBottom := l.offset + l.height + + if itemBottom > viewportBottom { + // Scroll so item bottom is at viewport bottom + l.offset = itemBottom - l.height + l.clampOffset() + l.dirtyViewport = true + } +} + +// Mouse interaction methods + +// HandleMouseDown handles mouse button down events. +// Returns true if the event was handled. +func (l *LazyList) HandleMouseDown(x, y int) bool { + if x < 0 || y < 0 || x >= l.width || y >= l.height { + return false + } + + // Find which item was clicked + clickY := l.offset + y + itemIdx := l.findItemAtY(clickY) + + if itemIdx < 0 { + return false + } + + // Calculate item-relative Y position. + itemY := clickY - l.getItemPosition(itemIdx) + + l.mouseDown = true + l.mouseDownItem = itemIdx + l.mouseDownX = x + l.mouseDownY = itemY + l.mouseDragItem = itemIdx + l.mouseDragX = x + l.mouseDragY = itemY + + // Select the clicked item + l.SetSelected(itemIdx) + + return true +} + +// HandleMouseDrag handles mouse drag events. +func (l *LazyList) HandleMouseDrag(x, y int) { + if !l.mouseDown { + return + } + + // Find item under cursor + if y >= 0 && y < l.height { + dragY := l.offset + y + itemIdx := l.findItemAtY(dragY) + if itemIdx >= 0 { + l.mouseDragItem = itemIdx + // Calculate item-relative Y position. + l.mouseDragY = dragY - l.getItemPosition(itemIdx) + l.mouseDragX = x + } + } + + // Update highlight if item supports it. + l.updateHighlight() +} + +// HandleMouseUp handles mouse button up events. +func (l *LazyList) HandleMouseUp(x, y int) { + if !l.mouseDown { + return + } + + l.mouseDown = false + + // Final highlight update. + l.updateHighlight() +} + +// findItemAtY finds the item index at the given Y coordinate (in content space, not viewport). +func (l *LazyList) findItemAtY(y int) int { + if y < 0 || len(l.items) == 0 { + return -1 + } + + pos := 0 + for i := 0; i < len(l.items); i++ { + itemHeight := l.itemHeights[i].height + if y >= pos && y < pos+itemHeight { + return i + } + pos += itemHeight + } + + return -1 +} + +// updateHighlight updates the highlight range for highlightable items. +// Supports highlighting within a single item and respects drag direction. +func (l *LazyList) updateHighlight() { + if l.mouseDownItem < 0 { + return + } + + // Get start and end item indices. + downItemIdx := l.mouseDownItem + dragItemIdx := l.mouseDragItem + + // Determine selection direction. + draggingDown := dragItemIdx > downItemIdx || + (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) || + (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX) + + // Determine actual start and end based on direction. + var startItemIdx, endItemIdx int + var startLine, startCol, endLine, endCol int + + if draggingDown { + // Normal forward selection. + startItemIdx = downItemIdx + endItemIdx = dragItemIdx + startLine = l.mouseDownY + startCol = l.mouseDownX + endLine = l.mouseDragY + endCol = l.mouseDragX + } else { + // Backward selection (dragging up). + startItemIdx = dragItemIdx + endItemIdx = downItemIdx + startLine = l.mouseDragY + startCol = l.mouseDragX + endLine = l.mouseDownY + endCol = l.mouseDownX + } + + // Clear all highlights first. + for i, item := range l.items { + if h, ok := item.(Highlightable); ok { + h.SetHighlight(-1, -1, -1, -1) + delete(l.renderedCache, i) + l.dirtyItems[i] = true + } + } + + // Highlight all items in range. + for idx := startItemIdx; idx <= endItemIdx; idx++ { + item, ok := l.items[idx].(Highlightable) + if !ok { + continue + } + + if idx == startItemIdx && idx == endItemIdx { + // Single item selection. + item.SetHighlight(startLine, startCol, endLine, endCol) + } else if idx == startItemIdx { + // First item - from start position to end of item. + itemHeight := l.itemHeights[idx].height + item.SetHighlight(startLine, startCol, itemHeight-1, 9999) // 9999 = end of line + } else if idx == endItemIdx { + // Last item - from start of item to end position. + item.SetHighlight(0, 0, endLine, endCol) + } else { + // Middle item - fully highlighted. + itemHeight := l.itemHeights[idx].height + item.SetHighlight(0, 0, itemHeight-1, 9999) + } + + delete(l.renderedCache, idx) + l.dirtyItems[idx] = true + } +} + +// ClearHighlight clears any active text highlighting. +func (l *LazyList) ClearHighlight() { + for i, item := range l.items { + if h, ok := item.(Highlightable); ok { + h.SetHighlight(-1, -1, -1, -1) + delete(l.renderedCache, i) + l.dirtyItems[i] = true + } + } + l.mouseDownItem = -1 + l.mouseDragItem = -1 +} + +// GetHighlightedText returns the plain text content of all highlighted regions +// across items, without any styling. Returns empty string if no highlights exist. +func (l *LazyList) GetHighlightedText() string { + var result strings.Builder + + // Iterate through items to find highlighted ones. + for i, item := range l.items { + h, ok := item.(Highlightable) + if !ok { + continue + } + + startLine, startCol, endLine, endCol := h.GetHighlight() + if startLine < 0 { + continue + } + + // Ensure item is rendered so we can access its buffer. + if _, ok := l.renderedCache[i]; !ok { + l.renderItem(i) + } + + cached := l.renderedCache[i] + if cached == nil || cached.buffer == nil { + continue + } + + buf := cached.buffer + itemHeight := cached.height + + // Extract text from highlighted region in item buffer. + for y := startLine; y <= endLine && y < itemHeight; y++ { + if y >= buf.Height() { + break + } + + line := buf.Line(y) + + // Determine column range for this line. + colStart := 0 + if y == startLine { + colStart = startCol + } + + colEnd := len(line) + if y == endLine { + colEnd = min(endCol, len(line)) + } + + // Track last non-empty position to trim trailing spaces. + lastContentX := -1 + for x := colStart; x < colEnd && x < len(line); x++ { + cell := line.At(x) + if cell == nil || cell.IsZero() { + continue + } + if cell.Content != "" && cell.Content != " " { + lastContentX = x + } + } + + // Extract text from cells, up to last content. + endX := colEnd + if lastContentX >= 0 { + endX = lastContentX + 1 + } + + for x := colStart; x < endX && x < len(line); x++ { + cell := line.At(x) + if cell != nil && !cell.IsZero() { + result.WriteString(cell.Content) + } + } + + // Add newline if not the last line. + if y < endLine { + result.WriteString("\n") + } + } + + // Add newline between items if this isn't the last highlighted item. + if i < len(l.items)-1 { + nextHasHighlight := false + for j := i + 1; j < len(l.items); j++ { + if h, ok := l.items[j].(Highlightable); ok { + s, _, _, _ := h.GetHighlight() + if s >= 0 { + nextHasHighlight = true + break + } + } + } + if nextHasHighlight { + result.WriteString("\n") + } + } + } + + return result.String() +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/ui/list/simplelist.go b/internal/ui/list/simplelist.go new file mode 100644 index 0000000000000000000000000000000000000000..10cb0912d42a8d25f2ea635ff69ea41653bbdbce --- /dev/null +++ b/internal/ui/list/simplelist.go @@ -0,0 +1,972 @@ +package list + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/exp/ordered" +) + +const maxGapSize = 100 + +var newlineBuffer = strings.Repeat("\n", maxGapSize) + +// SimpleList is a string-based list with virtual scrolling behavior. +// Based on exp/list but simplified for our needs. +type SimpleList struct { + // Viewport dimensions. + width, height int + + // Scroll offset (in lines from top). + offset int + + // Items. + items []Item + itemIDs map[string]int // ID -> index mapping + + // Rendered content (all items stacked). + rendered string + renderedHeight int // Total height of rendered content in lines + lineOffsets []int // Byte offsets for each line (for fast slicing) + + // Rendered item metadata. + renderedItems map[string]renderedItem + + // Selection. + selectedIdx int + focused bool + + // Focus tracking. + prevSelectedIdx int + + // Mouse/highlight state. + mouseDown bool + mouseDownItem int + mouseDownX int + mouseDownY int // viewport-relative Y + mouseDragItem int + mouseDragX int + mouseDragY int // viewport-relative Y + selectionStartLine int + selectionStartCol int + selectionEndLine int + selectionEndCol int + + // Configuration. + gap int // Gap between items in lines +} + +type renderedItem struct { + view string + height int + start int // Start line in rendered content + end int // End line in rendered content +} + +// NewSimpleList creates a new simple list. +func NewSimpleList(items ...Item) *SimpleList { + l := &SimpleList{ + items: items, + itemIDs: make(map[string]int, len(items)), + renderedItems: make(map[string]renderedItem), + selectedIdx: -1, + prevSelectedIdx: -1, + gap: 0, + selectionStartLine: -1, + selectionStartCol: -1, + selectionEndLine: -1, + selectionEndCol: -1, + } + + // Build ID map. + for i, item := range items { + if idItem, ok := item.(interface{ ID() string }); ok { + l.itemIDs[idItem.ID()] = i + } + } + + return l +} + +// Init initializes the list (Bubbletea lifecycle). +func (l *SimpleList) Init() tea.Cmd { + return l.render() +} + +// Update handles messages (Bubbletea lifecycle). +func (l *SimpleList) Update(msg tea.Msg) (*SimpleList, tea.Cmd) { + return l, nil +} + +// View returns the visible viewport (Bubbletea lifecycle). +func (l *SimpleList) View() string { + if l.height <= 0 || l.width <= 0 { + return "" + } + + start, end := l.viewPosition() + viewStart := max(0, start) + viewEnd := end + + if viewStart > viewEnd { + return "" + } + + view := l.getLines(viewStart, viewEnd) + + // Apply width/height constraints. + view = lipgloss.NewStyle(). + Height(l.height). + Width(l.width). + Render(view) + + // Apply highlighting if active. + if l.hasSelection() { + return l.renderSelection(view) + } + + return view +} + +// viewPosition returns the start and end line indices for the viewport. +func (l *SimpleList) viewPosition() (int, int) { + start := max(0, l.offset) + end := min(l.offset+l.height-1, l.renderedHeight-1) + start = min(start, end) + return start, end +} + +// getLines returns lines [start, end] from rendered content. +func (l *SimpleList) 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 // Exclude newline + } else { + endOffset = len(l.rendered) + } + + if startOffset >= len(l.rendered) { + return "" + } + endOffset = min(endOffset, len(l.rendered)) + + return l.rendered[startOffset:endOffset] +} + +// render rebuilds the rendered content from all items. +func (l *SimpleList) render() tea.Cmd { + if l.width <= 0 || l.height <= 0 || len(l.items) == 0 { + return nil + } + + // Set default selection if none. + if l.selectedIdx < 0 && len(l.items) > 0 { + l.selectedIdx = 0 + } + + // Handle focus changes. + var focusCmd tea.Cmd + if l.focused { + focusCmd = l.focusSelectedItem() + } else { + focusCmd = l.blurSelectedItem() + } + + // Render all items. + var b strings.Builder + currentLine := 0 + + for i, item := range l.items { + // Render item. + view := l.renderItem(item) + height := lipgloss.Height(view) + + // Store metadata. + rItem := renderedItem{ + view: view, + height: height, + start: currentLine, + end: currentLine + height - 1, + } + + if idItem, ok := item.(interface{ ID() string }); ok { + l.renderedItems[idItem.ID()] = rItem + } + + // Append to rendered content. + b.WriteString(view) + + // Add gap after item (except last). + gap := l.gap + if i == len(l.items)-1 { + gap = 0 + } + + if gap > 0 { + if gap <= maxGapSize { + b.WriteString(newlineBuffer[:gap]) + } else { + b.WriteString(strings.Repeat("\n", gap)) + } + } + + currentLine += height + gap + } + + l.setRendered(b.String()) + + // Scroll to selected item. + if l.focused && l.selectedIdx >= 0 { + l.scrollToSelection() + } + + return focusCmd +} + +// renderItem renders a single item. +func (l *SimpleList) renderItem(item Item) string { + // Create a buffer for the item. + buf := uv.NewScreenBuffer(l.width, 1000) // Max height + area := uv.Rect(0, 0, l.width, 1000) + item.Draw(&buf, area) + + // Find actual height. + height := l.measureBufferHeight(&buf) + if height == 0 { + height = 1 + } + + // Render to string. + return buf.Render() +} + +// measureBufferHeight finds the actual content height in a buffer. +func (l *SimpleList) measureBufferHeight(buf *uv.ScreenBuffer) int { + height := buf.Height() + + // Scan from bottom up to find last non-empty line. + for y := height - 1; y >= 0; y-- { + line := buf.Line(y) + if l.lineHasContent(line) { + return y + 1 + } + } + + return 0 +} + +// lineHasContent checks if a line has any non-empty cells. +func (l *SimpleList) lineHasContent(line uv.Line) bool { + for x := 0; x < len(line); x++ { + cell := line.At(x) + if cell != nil && !cell.IsZero() && cell.Content != "" && cell.Content != " " { + return true + } + } + return false +} + +// setRendered updates the rendered content and caches line offsets. +func (l *SimpleList) setRendered(rendered string) { + l.rendered = rendered + l.renderedHeight = lipgloss.Height(rendered) + + // Build line offset cache. + 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 + } +} + +// scrollToSelection scrolls to make the selected item visible. +func (l *SimpleList) scrollToSelection() { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return + } + + // Get selected item metadata. + var rItem *renderedItem + if idItem, ok := l.items[l.selectedIdx].(interface{ ID() string }); ok { + if ri, ok := l.renderedItems[idItem.ID()]; ok { + rItem = &ri + } + } + + if rItem == nil { + return + } + + start, end := l.viewPosition() + + // Already visible. + if rItem.start >= start && rItem.end <= end { + return + } + + // Item is above viewport - scroll up. + if rItem.start < start { + l.offset = rItem.start + return + } + + // Item is below viewport - scroll down. + if rItem.end > end { + l.offset = max(0, rItem.end-l.height+1) + } +} + +// Focus/blur management. + +func (l *SimpleList) focusSelectedItem() tea.Cmd { + if l.selectedIdx < 0 || !l.focused { + return nil + } + + var cmds []tea.Cmd + + // Blur previous. + if l.prevSelectedIdx >= 0 && l.prevSelectedIdx != l.selectedIdx && l.prevSelectedIdx < len(l.items) { + if f, ok := l.items[l.prevSelectedIdx].(Focusable); ok && f.IsFocused() { + f.Blur() + } + } + + // Focus current. + if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) { + if f, ok := l.items[l.selectedIdx].(Focusable); ok && !f.IsFocused() { + f.Focus() + } + } + + l.prevSelectedIdx = l.selectedIdx + return tea.Batch(cmds...) +} + +func (l *SimpleList) blurSelectedItem() tea.Cmd { + if l.selectedIdx < 0 || l.focused { + return nil + } + + if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) { + if f, ok := l.items[l.selectedIdx].(Focusable); ok && f.IsFocused() { + f.Blur() + } + } + + return nil +} + +// Public API. + +// SetSize sets the viewport dimensions. +func (l *SimpleList) SetSize(width, height int) tea.Cmd { + oldWidth := l.width + l.width = width + l.height = height + + if oldWidth != width { + // Width changed - need to re-render. + return l.render() + } + + return nil +} + +// Width returns the viewport width. +func (l *SimpleList) Width() int { + return l.width +} + +// Height returns the viewport height. +func (l *SimpleList) Height() int { + return l.height +} + +// GetSize returns the viewport dimensions. +func (l *SimpleList) GetSize() (int, int) { + return l.width, l.height +} + +// Items returns all items. +func (l *SimpleList) Items() []Item { + return l.items +} + +// Len returns the number of items. +func (l *SimpleList) Len() int { + return len(l.items) +} + +// SetItems replaces all items. +func (l *SimpleList) SetItems(items []Item) tea.Cmd { + l.items = items + l.itemIDs = make(map[string]int, len(items)) + l.renderedItems = make(map[string]renderedItem) + l.selectedIdx = -1 + l.prevSelectedIdx = -1 + l.offset = 0 + + // Build ID map. + for i, item := range items { + if idItem, ok := item.(interface{ ID() string }); ok { + l.itemIDs[idItem.ID()] = i + } + } + + return l.render() +} + +// AppendItem adds an item to the end. +func (l *SimpleList) AppendItem(item Item) tea.Cmd { + l.items = append(l.items, item) + + if idItem, ok := item.(interface{ ID() string }); ok { + l.itemIDs[idItem.ID()] = len(l.items) - 1 + } + + return l.render() +} + +// PrependItem adds an item to the beginning. +func (l *SimpleList) PrependItem(item Item) tea.Cmd { + l.items = append([]Item{item}, l.items...) + + // Rebuild ID map (indices shifted). + l.itemIDs = make(map[string]int, len(l.items)) + for i, it := range l.items { + if idItem, ok := it.(interface{ ID() string }); ok { + l.itemIDs[idItem.ID()] = i + } + } + + // Adjust selection. + if l.selectedIdx >= 0 { + l.selectedIdx++ + } + if l.prevSelectedIdx >= 0 { + l.prevSelectedIdx++ + } + + return l.render() +} + +// UpdateItem replaces an item at the given index. +func (l *SimpleList) UpdateItem(idx int, item Item) tea.Cmd { + if idx < 0 || idx >= len(l.items) { + return nil + } + + l.items[idx] = item + + // Update ID map. + if idItem, ok := item.(interface{ ID() string }); ok { + l.itemIDs[idItem.ID()] = idx + } + + return l.render() +} + +// DeleteItem removes an item at the given index. +func (l *SimpleList) DeleteItem(idx int) tea.Cmd { + if idx < 0 || idx >= len(l.items) { + return nil + } + + l.items = append(l.items[:idx], l.items[idx+1:]...) + + // Rebuild ID map (indices shifted). + l.itemIDs = make(map[string]int, len(l.items)) + for i, it := range l.items { + if idItem, ok := it.(interface{ ID() string }); ok { + l.itemIDs[idItem.ID()] = i + } + } + + // Adjust selection. + if l.selectedIdx == idx { + if idx > 0 { + l.selectedIdx = idx - 1 + } else if len(l.items) > 0 { + l.selectedIdx = 0 + } else { + l.selectedIdx = -1 + } + } else if l.selectedIdx > idx { + l.selectedIdx-- + } + + if l.prevSelectedIdx == idx { + l.prevSelectedIdx = -1 + } else if l.prevSelectedIdx > idx { + l.prevSelectedIdx-- + } + + return l.render() +} + +// Focus sets the list as focused. +func (l *SimpleList) Focus() tea.Cmd { + l.focused = true + return l.render() +} + +// Blur removes focus from the list. +func (l *SimpleList) Blur() tea.Cmd { + l.focused = false + return l.render() +} + +// Focused returns whether the list is focused. +func (l *SimpleList) Focused() bool { + return l.focused +} + +// Selection. + +// Selected returns the currently selected item index. +func (l *SimpleList) Selected() int { + return l.selectedIdx +} + +// SelectedIndex returns the currently selected item index. +func (l *SimpleList) SelectedIndex() int { + return l.selectedIdx +} + +// SelectedItem returns the currently selected item. +func (l *SimpleList) SelectedItem() Item { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return nil + } + return l.items[l.selectedIdx] +} + +// SetSelected sets the selected item by index. +func (l *SimpleList) SetSelected(idx int) tea.Cmd { + if idx < -1 || idx >= len(l.items) { + return nil + } + + if l.selectedIdx == idx { + return nil + } + + l.prevSelectedIdx = l.selectedIdx + l.selectedIdx = idx + + return l.render() +} + +// SelectFirst selects the first item. +func (l *SimpleList) SelectFirst() tea.Cmd { + return l.SetSelected(0) +} + +// SelectLast selects the last item. +func (l *SimpleList) SelectLast() tea.Cmd { + if len(l.items) > 0 { + return l.SetSelected(len(l.items) - 1) + } + return nil +} + +// SelectNext selects the next item. +func (l *SimpleList) SelectNext() tea.Cmd { + if l.selectedIdx < len(l.items)-1 { + return l.SetSelected(l.selectedIdx + 1) + } + return nil +} + +// SelectPrev selects the previous item. +func (l *SimpleList) SelectPrev() tea.Cmd { + if l.selectedIdx > 0 { + return l.SetSelected(l.selectedIdx - 1) + } + return nil +} + +// SelectNextWrap selects the next item (wraps to beginning). +func (l *SimpleList) SelectNextWrap() tea.Cmd { + if len(l.items) == 0 { + return nil + } + nextIdx := (l.selectedIdx + 1) % len(l.items) + return l.SetSelected(nextIdx) +} + +// SelectPrevWrap selects the previous item (wraps to end). +func (l *SimpleList) SelectPrevWrap() tea.Cmd { + if len(l.items) == 0 { + return nil + } + prevIdx := (l.selectedIdx - 1 + len(l.items)) % len(l.items) + return l.SetSelected(prevIdx) +} + +// SelectFirstInView selects the first fully visible item. +func (l *SimpleList) SelectFirstInView() tea.Cmd { + if len(l.items) == 0 { + return nil + } + + start, end := l.viewPosition() + + for i := 0; i < len(l.items); i++ { + if idItem, ok := l.items[i].(interface{ ID() string }); ok { + if rItem, ok := l.renderedItems[idItem.ID()]; ok { + // Check if fully visible. + if rItem.start >= start && rItem.end <= end { + return l.SetSelected(i) + } + } + } + } + + return nil +} + +// SelectLastInView selects the last fully visible item. +func (l *SimpleList) SelectLastInView() tea.Cmd { + if len(l.items) == 0 { + return nil + } + + start, end := l.viewPosition() + + for i := len(l.items) - 1; i >= 0; i-- { + if idItem, ok := l.items[i].(interface{ ID() string }); ok { + if rItem, ok := l.renderedItems[idItem.ID()]; ok { + // Check if fully visible. + if rItem.start >= start && rItem.end <= end { + return l.SetSelected(i) + } + } + } + } + + return nil +} + +// SelectedItemInView returns true if the selected item is visible. +func (l *SimpleList) SelectedItemInView() bool { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return false + } + + var rItem *renderedItem + if idItem, ok := l.items[l.selectedIdx].(interface{ ID() string }); ok { + if ri, ok := l.renderedItems[idItem.ID()]; ok { + rItem = &ri + } + } + + if rItem == nil { + return false + } + + start, end := l.viewPosition() + return rItem.start < end && rItem.end > start +} + +// Scrolling. + +// Offset returns the current scroll offset. +func (l *SimpleList) Offset() int { + return l.offset +} + +// TotalHeight returns the total height of all items. +func (l *SimpleList) TotalHeight() int { + return l.renderedHeight +} + +// ScrollBy scrolls by the given number of lines. +func (l *SimpleList) ScrollBy(deltaLines int) tea.Cmd { + l.offset += deltaLines + l.clampOffset() + return nil +} + +// ScrollToTop scrolls to the top. +func (l *SimpleList) ScrollToTop() tea.Cmd { + l.offset = 0 + return nil +} + +// ScrollToBottom scrolls to the bottom. +func (l *SimpleList) ScrollToBottom() tea.Cmd { + l.offset = l.renderedHeight - l.height + l.clampOffset() + return nil +} + +// AtTop returns true if scrolled to the top. +func (l *SimpleList) AtTop() bool { + return l.offset <= 0 +} + +// AtBottom returns true if scrolled to the bottom. +func (l *SimpleList) AtBottom() bool { + return l.offset >= l.renderedHeight-l.height +} + +// ScrollToItem scrolls to make an item visible. +func (l *SimpleList) ScrollToItem(idx int) tea.Cmd { + if idx < 0 || idx >= len(l.items) { + return nil + } + + var rItem *renderedItem + if idItem, ok := l.items[idx].(interface{ ID() string }); ok { + if ri, ok := l.renderedItems[idItem.ID()]; ok { + rItem = &ri + } + } + + if rItem == nil { + return nil + } + + start, end := l.viewPosition() + + // Already visible. + if rItem.start >= start && rItem.end <= end { + return nil + } + + // Above viewport. + if rItem.start < start { + l.offset = rItem.start + return nil + } + + // Below viewport. + if rItem.end > end { + l.offset = rItem.end - l.height + 1 + l.clampOffset() + } + + return nil +} + +// ScrollToSelected scrolls to the selected item. +func (l *SimpleList) ScrollToSelected() tea.Cmd { + if l.selectedIdx >= 0 { + return l.ScrollToItem(l.selectedIdx) + } + return nil +} + +func (l *SimpleList) clampOffset() { + maxOffset := l.renderedHeight - l.height + if maxOffset < 0 { + maxOffset = 0 + } + l.offset = ordered.Clamp(l.offset, 0, maxOffset) +} + +// Mouse and highlighting. + +// HandleMouseDown handles mouse press. +func (l *SimpleList) HandleMouseDown(x, y int) bool { + if x < 0 || y < 0 || x >= l.width || y >= l.height { + return false + } + + // Find item at viewport y. + contentY := l.offset + y + itemIdx := l.findItemAtLine(contentY) + + if itemIdx < 0 { + return false + } + + l.mouseDown = true + l.mouseDownItem = itemIdx + l.mouseDownX = x + l.mouseDownY = y + l.mouseDragItem = itemIdx + l.mouseDragX = x + l.mouseDragY = y + + // Start selection. + l.selectionStartLine = y + l.selectionStartCol = x + l.selectionEndLine = y + l.selectionEndCol = x + + // Select item. + l.SetSelected(itemIdx) + + return true +} + +// HandleMouseDrag handles mouse drag. +func (l *SimpleList) HandleMouseDrag(x, y int) bool { + if !l.mouseDown { + return false + } + + // Clamp coordinates to viewport bounds. + clampedX := max(0, min(x, l.width-1)) + clampedY := max(0, min(y, l.height-1)) + + if clampedY >= 0 && clampedY < l.height { + contentY := l.offset + clampedY + itemIdx := l.findItemAtLine(contentY) + if itemIdx >= 0 { + l.mouseDragItem = itemIdx + l.mouseDragX = clampedX + l.mouseDragY = clampedY + } + } + + // Update selection end (clamped to viewport). + l.selectionEndLine = clampedY + l.selectionEndCol = clampedX + + return true +} + +// HandleMouseUp handles mouse release. +func (l *SimpleList) HandleMouseUp(x, y int) bool { + if !l.mouseDown { + return false + } + + l.mouseDown = false + + // Final selection update (clamped to viewport). + clampedX := max(0, min(x, l.width-1)) + clampedY := max(0, min(y, l.height-1)) + l.selectionEndLine = clampedY + l.selectionEndCol = clampedX + + return true +} + +// ClearHighlight clears the selection. +func (l *SimpleList) ClearHighlight() { + l.selectionStartLine = -1 + l.selectionStartCol = -1 + l.selectionEndLine = -1 + l.selectionEndCol = -1 + l.mouseDown = false + l.mouseDownItem = -1 + l.mouseDragItem = -1 +} + +// GetHighlightedText returns the selected text. +func (l *SimpleList) GetHighlightedText() string { + if !l.hasSelection() { + return "" + } + + return l.renderSelection(l.View()) +} + +func (l *SimpleList) hasSelection() bool { + return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine +} + +// renderSelection applies highlighting to the view and extracts text. +func (l *SimpleList) renderSelection(view string) string { + // Create a screen buffer spanning the viewport. + buf := uv.NewScreenBuffer(l.width, l.height) + area := uv.Rect(0, 0, l.width, l.height) + uv.NewStyledString(view).Draw(&buf, area) + + // Calculate selection bounds. + startLine := min(l.selectionStartLine, l.selectionEndLine) + endLine := max(l.selectionStartLine, l.selectionEndLine) + startCol := l.selectionStartCol + endCol := l.selectionEndCol + + if l.selectionEndLine < l.selectionStartLine { + startCol = l.selectionEndCol + endCol = l.selectionStartCol + } + + // Apply highlighting. + for y := startLine; y <= endLine && y < l.height; y++ { + if y >= buf.Height() { + break + } + + line := buf.Line(y) + + // Determine column range for this line. + colStart := 0 + if y == startLine { + colStart = startCol + } + + colEnd := len(line) + if y == endLine { + colEnd = min(endCol, len(line)) + } + + // Apply highlight style. + for x := colStart; x < colEnd && x < len(line); x++ { + cell := line.At(x) + if cell != nil && !cell.IsZero() { + cell = cell.Clone() + // Toggle reverse for highlight. + if cell.Style.Attrs&uv.AttrReverse != 0 { + cell.Style.Attrs &^= uv.AttrReverse + } else { + cell.Style.Attrs |= uv.AttrReverse + } + buf.SetCell(x, y, cell) + } + } + } + + return buf.Render() +} + +// findItemAtLine finds the item index at the given content line. +func (l *SimpleList) findItemAtLine(line int) int { + for i := 0; i < len(l.items); i++ { + if idItem, ok := l.items[i].(interface{ ID() string }); ok { + if rItem, ok := l.renderedItems[idItem.ID()]; ok { + if line >= rItem.start && line <= rItem.end { + return i + } + } + } + } + return -1 +} + +// Render returns the view (for compatibility). +func (l *SimpleList) Render() string { + return l.View() +} diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 9b3f4967f374380b46bfcc813136597c3812c26f..8872081b01794c5a3acad1d0057c6b4d49fea8da 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/lazylist" "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" uv "github.com/charmbracelet/ultraviolet" @@ -196,13 +197,14 @@ func (c *ChatMessageItem) SetHighlight(startLine int, startCol int, endLine int, // messages. type Chat struct { com *common.Common - list *list.List + list *lazylist.List } // NewChat creates a new instance of [Chat] that handles chat interactions and // messages. func NewChat(com *common.Common) *Chat { - l := list.New() + l := lazylist.NewList() + l.SetGap(1) return &Chat{ com: com, list: l, @@ -216,7 +218,7 @@ func (m *Chat) Height() int { // Draw renders the chat UI component to the screen and the given area. func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) { - m.list.Draw(scr, area) + uv.NewStyledString(m.list.Render()).Draw(scr, area) } // SetSize sets the size of the chat view port. @@ -229,25 +231,23 @@ func (m *Chat) Len() int { return m.list.Len() } -// PrependItem prepends a new item to the chat list. -func (m *Chat) PrependItem(item list.Item) { - m.list.PrependItem(item) +// PrependItems prepends new items to the chat list. +func (m *Chat) PrependItems(items ...lazylist.Item) { + m.list.PrependItems(items...) + m.list.ScrollToIndex(0) } // AppendMessages appends a new message item to the chat list. func (m *Chat) AppendMessages(msgs ...MessageItem) { for _, msg := range msgs { - m.AppendItem(msg) + m.AppendItems(msg) } } -// AppendItem appends a new item to the chat list. -func (m *Chat) AppendItem(item list.Item) { - if m.Len() > 0 { - // Always add a spacer between messages - m.list.AppendItem(list.NewSpacerItem(1)) - } - m.list.AppendItem(item) +// AppendItems appends new items to the chat list. +func (m *Chat) AppendItems(items ...lazylist.Item) { + m.list.AppendItems(items...) + m.list.ScrollToIndex(m.list.Len() - 1) } // Focus sets the focus state of the chat component. diff --git a/internal/ui/model/items.go b/internal/ui/model/items.go index 7df9f8c052400da3e9198513dcd37bc4d67d41ec..48bf79d2f84c0000cd0c8c7e07b9da44401997fc 100644 --- a/internal/ui/model/items.go +++ b/internal/ui/model/items.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/lazylist" "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/ui/toolrender" @@ -29,6 +30,7 @@ type MessageItem interface { list.Item list.Focusable list.Highlightable + lazylist.Item Identifiable } @@ -114,6 +116,11 @@ func (m *MessageContentItem) Draw(scr uv.Screen, area uv.Rectangle) { tempBuf.Draw(scr, area) } +// Render implements lazylist.Item. +func (m *MessageContentItem) Render(width int) string { + return m.render(width) +} + // render renders the content at the given width, using cache if available. func (m *MessageContentItem) render(width int) string { // Cap width to maxWidth for markdown @@ -228,6 +235,12 @@ func (t *ToolCallItem) Height(width int) int { return height } +// Render implements lazylist.Item. +func (t *ToolCallItem) Render(width int) string { + cached := t.renderCached(width) + return cached.content +} + // Draw implements list.Item. func (t *ToolCallItem) Draw(scr uv.Screen, area uv.Rectangle) { width := area.Dx() @@ -334,6 +347,16 @@ func (a *AttachmentItem) Height(width int) int { return 1 } +// Render implements lazylist.Item. +func (a *AttachmentItem) Render(width int) string { + const maxFilenameWidth = 10 + return a.sty.Chat.Message.Attachment.Render(fmt.Sprintf( + " %s %s ", + styles.DocumentIcon, + ansi.Truncate(a.filename, maxFilenameWidth, "..."), + )) +} + // Draw implements list.Item. func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) { width := area.Dx() @@ -410,6 +433,11 @@ func (t *ThinkingItem) Height(width int) int { return strings.Count(rendered, "\n") + 1 } +// Render implements lazylist.Item. +func (t *ThinkingItem) Render(width int) string { + return t.render(width) +} + // Draw implements list.Item. func (t *ThinkingItem) Draw(scr uv.Screen, area uv.Rectangle) { width := area.Dx() @@ -522,6 +550,15 @@ func (s *SectionHeaderItem) Height(width int) int { return 1 } +// Render implements lazylist.Item. +func (s *SectionHeaderItem) Render(width int) string { + return s.sty.Chat.Message.SectionHeader.Render(fmt.Sprintf("%s %s %s", + s.sty.Subtle.Render(styles.ModelIcon), + s.sty.Muted.Render(s.modelName), + s.sty.Subtle.Render(s.duration.String()), + )) +} + // Draw implements list.Item. func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) { width := area.Dx() diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 09c3155dfd597ab55175f0ac079ce03e69494b5b..5e24004d84a56d8c07bad623e59c7c2df6cdb31b 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -172,7 +172,7 @@ func (m *UI) Init() tea.Cmd { if len(allSessions) > 0 { cmds = append(cmds, func() tea.Msg { time.Sleep(2 * time.Second) - return m.loadSession(allSessions[0].ID)() + return m.loadSession(allSessions[1].ID)() }) } return tea.Batch(cmds...) @@ -441,12 +441,12 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { editor.Draw(scr, layout.editor) case uiChat: + m.chat.Draw(scr, layout.main) + header := uv.NewStyledString(m.header) header.Draw(scr, layout.header) m.drawSidebar(scr, layout.sidebar) - m.chat.Draw(scr, layout.main) - editor := uv.NewStyledString(m.textarea.View()) editor.Draw(scr, layout.editor) From 953d181984a5daf537bba6a7c713c343e2e55d6a Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 8 Dec 2025 19:42:03 -0500 Subject: [PATCH 031/335] feat(ui): optimize ScrollToBottom in lazylist --- internal/ui/lazylist/list.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/internal/ui/lazylist/list.go b/internal/ui/lazylist/list.go index 70862f80b5efd6e3379c01676c1211b0b32221e0..4a2e47e02b6d8c677d5fecf9ee3fefb7c47f475a 100644 --- a/internal/ui/lazylist/list.go +++ b/internal/ui/lazylist/list.go @@ -336,8 +336,7 @@ func (l *List) ScrollToBottom() { // Scroll to the last item var totalHeight int - var i int - for i = len(l.items) - 1; i >= 0; i-- { + for i := len(l.items) - 1; i >= 0; i-- { item := l.getItem(i) totalHeight += item.height if l.gap > 0 && i < len(l.items)-1 { @@ -349,10 +348,9 @@ func (l *List) ScrollToBottom() { break } } - if i < 0 { + if totalHeight < l.height { // All items fit in the viewport - l.offsetIdx = 0 - l.offsetLine = 0 + l.ScrollToTop() } } From f79c9ce48407073360b55c21c008567d28b56524 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 9 Dec 2025 09:56:53 -0500 Subject: [PATCH 032/335] fix(ui): fix scrolling up last item --- internal/ui/lazylist/list.go | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/internal/ui/lazylist/list.go b/internal/ui/lazylist/list.go index 4a2e47e02b6d8c677d5fecf9ee3fefb7c47f475a..9d6559b7540a97131c0f9d5c08c037c5279750a7 100644 --- a/internal/ui/lazylist/list.go +++ b/internal/ui/lazylist/list.go @@ -170,27 +170,37 @@ func (l *List) ScrollBy(lines int) { } } else if lines < 0 { // Scroll up - l.offsetLine += lines - for l.offsetLine < 0 { - if l.offsetIdx == 0 { - // Reached the top of the list - l.offsetLine = 0 + // Calculate from offset how many items needed to fill the viewport + // This is needed to know when to stop scrolling up + var totalLines int + var firstItemIdx int + for i := l.offsetIdx; i >= 0; i-- { + item := l.getItem(i) + totalLines += item.height + if l.gap > 0 && i < l.offsetIdx { + totalLines += l.gap + } + if totalLines >= l.height { + firstItemIdx = i break } + } + // Now scroll up by lines + l.offsetLine += lines // lines is negative + for l.offsetIdx > firstItemIdx && l.offsetLine < 0 { // Move to previous item l.offsetIdx-- - item := l.getItem(l.offsetIdx) - totalHeight := item.height + prevItem := l.getItem(l.offsetIdx) + totalHeight := prevItem.height if l.gap > 0 { totalHeight += l.gap } l.offsetLine += totalHeight } - item := l.getItem(l.offsetIdx) - if l.offsetLine >= item.height { - l.offsetLine = item.height - 1 + if l.offsetLine < 0 { + l.offsetLine = 0 } } } From 94de6bc884658ed35797ff0dbca1ed5571af3e14 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 9 Dec 2025 11:01:19 -0500 Subject: [PATCH 033/335] refactor(ui): remove dirty tracking from LazyList --- internal/ui/lazylist/item.go | 22 ++++++++++++++++++++++ internal/ui/lazylist/list.go | 19 ++++--------------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/internal/ui/lazylist/item.go b/internal/ui/lazylist/item.go index dc39451ea5259a06d68b9dc45e78fdc00a2dac24..3ec31fbcf3d091e8d3438b2ecccfd467658b5f0f 100644 --- a/internal/ui/lazylist/item.go +++ b/internal/ui/lazylist/item.go @@ -6,3 +6,25 @@ type Item interface { // width. Render(width int) string } + +// Focusable represents an item that can gain or lose focus. +type Focusable interface { + // Focus sets the focus state of the item. + Focus() + + // Blur removes the focus state of the item. + Blur() + + // Focused returns whether the item is focused. + Focused() bool +} + +// Highlightable represents an item that can have a highlighted region. +type Highlightable interface { + // SetHighlight sets the highlight region (startLine, startCol) to (endLine, endCol). + // Use -1 for all values to clear highlighting. + SetHighlight(startLine, startCol, endLine, endCol int) + + // GetHighlight returns the current highlight region. + GetHighlight() (startLine, startCol, endLine, endCol int) +} diff --git a/internal/ui/lazylist/list.go b/internal/ui/lazylist/list.go index 9d6559b7540a97131c0f9d5c08c037c5279750a7..334af80b263b5e64e3cf8cecad36ca410f47f71c 100644 --- a/internal/ui/lazylist/list.go +++ b/internal/ui/lazylist/list.go @@ -31,9 +31,6 @@ type List struct { // scrolled out of view (above the viewport). // It must always be >= 0. offsetLine int - - // Dirty tracking - dirtyItems map[int]struct{} } // renderedItem holds the rendered content and height of an item. @@ -47,7 +44,6 @@ func NewList(items ...Item) *List { l := new(List) l.items = items l.renderedItems = make(map[int]renderedItem) - l.dirtyItems = make(map[int]struct{}) return l } @@ -88,9 +84,7 @@ func (l *List) getItem(idx int) renderedItem { } if item, ok := l.renderedItems[idx]; ok { - if _, dirty := l.dirtyItems[idx]; !dirty { - return item - } + return item } item := l.items[idx] @@ -104,7 +98,6 @@ func (l *List) getItem(idx int) renderedItem { } l.renderedItems[idx] = ri - delete(l.dirtyItems, idx) return ri } @@ -301,13 +294,6 @@ func (l *List) PrependItems(items ...Item) { } l.renderedItems = newCache - // Shift dirty items - newDirty := make(map[int]struct{}) - for idx := range l.dirtyItems { - newDirty[idx+len(items)] = struct{}{} - } - l.dirtyItems = newDirty - // Keep view position relative to the content that was visible l.offsetIdx += len(items) @@ -325,6 +311,9 @@ func (l *List) AppendItems(items ...Item) { // Focus sets the focus state of the list. func (l *List) Focus() { l.focused = true + if l.selectedIdx < 0 || l.selectedIdx > len(l.items)-1 { + return + } } // Blur removes the focus state from the list. From 8d7e64fef8dbfdf31aa5bfa8831d7f1b114457e2 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 9 Dec 2025 11:04:09 -0500 Subject: [PATCH 034/335] feat(ui): invalidate rendered items on focus/blur --- internal/ui/lazylist/list.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal/ui/lazylist/list.go b/internal/ui/lazylist/list.go index 334af80b263b5e64e3cf8cecad36ca410f47f71c..8e81d96914130d5a8eebdf5ca5dd6d4ba5fb3e8d 100644 --- a/internal/ui/lazylist/list.go +++ b/internal/ui/lazylist/list.go @@ -102,6 +102,12 @@ func (l *List) getItem(idx int) renderedItem { return ri } +// invalidateItem invalidates the cached rendered content of the item at the +// given index. +func (l *List) invalidateItem(idx int) { + delete(l.renderedItems, idx) +} + // ScrollToIndex scrolls the list to the given item index. func (l *List) ScrollToIndex(index int) { if index < 0 { @@ -314,11 +320,26 @@ func (l *List) Focus() { if l.selectedIdx < 0 || l.selectedIdx > len(l.items)-1 { return } + + item := l.items[l.selectedIdx] + if focusable, ok := item.(Focusable); ok { + focusable.Focus() + l.invalidateItem(l.selectedIdx) + } } // Blur removes the focus state from the list. func (l *List) Blur() { l.focused = false + if l.selectedIdx < 0 || l.selectedIdx > len(l.items)-1 { + return + } + + item := l.items[l.selectedIdx] + if focusable, ok := item.(Focusable); ok { + focusable.Blur() + l.invalidateItem(l.selectedIdx) + } } // ScrollToTop scrolls the list to the top. From 1460d7577690c9b0e131442670c82911f6147e2b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 9 Dec 2025 11:11:30 -0500 Subject: [PATCH 035/335] fix(ui): scroll down off by one --- internal/ui/lazylist/list.go | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/internal/ui/lazylist/list.go b/internal/ui/lazylist/list.go index 8e81d96914130d5a8eebdf5ca5dd6d4ba5fb3e8d..bbf8d970852645643b5b5a227aa671dd3e00161a 100644 --- a/internal/ui/lazylist/list.go +++ b/internal/ui/lazylist/list.go @@ -138,7 +138,7 @@ func (l *List) ScrollBy(lines int) { if l.gap > 0 && i < len(l.items)-1 { totalLines += l.gap } - if totalLines >= l.height { + if totalLines > l.height-1 { lastItemIdx = i break } @@ -165,7 +165,7 @@ func (l *List) ScrollBy(lines int) { } if l.offsetLine >= item.height { - l.offsetLine = item.height - 1 + l.offsetLine = item.height } } else if lines < 0 { // Scroll up @@ -376,7 +376,36 @@ func (l *List) ScrollToBottom() { // ScrollToSelected scrolls the list to the selected item. func (l *List) ScrollToSelected() { - // TODO: Implement me + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return + } + + startIdx, endIdx := l.findVisibleItems() + if l.selectedIdx < startIdx { + // Selected item is above the visible range + l.offsetIdx = l.selectedIdx + l.offsetLine = 0 + } else if l.selectedIdx > endIdx { + // Selected item is below the visible range + // Scroll so that the selected item is at the bottom + var totalHeight int + for i := l.selectedIdx; i >= 0; i-- { + item := l.getItem(i) + totalHeight += item.height + if l.gap > 0 && i < l.selectedIdx { + totalHeight += l.gap + } + if totalHeight >= l.height { + l.offsetIdx = i + l.offsetLine = totalHeight - l.height + break + } + } + if totalHeight < l.height { + // All items fit in the viewport + l.ScrollToTop() + } + } } // SelectedItemInView returns whether the selected item is currently in view. From 9c001302115b716d9a00c787f9b61be3668d35a4 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 9 Dec 2025 18:13:25 -0500 Subject: [PATCH 036/335] feat(ui): add text highlighting support to lazylist --- internal/ui/lazylist/highlight.go | 289 ++++++++++++ internal/ui/lazylist/item.go | 30 +- internal/ui/lazylist/list.go | 419 ++++++++++++++++-- internal/ui/model/chat.go | 193 +------- internal/ui/model/items.go | 705 +++++++++++++++++------------- internal/ui/model/ui.go | 26 +- 6 files changed, 1121 insertions(+), 541 deletions(-) create mode 100644 internal/ui/lazylist/highlight.go diff --git a/internal/ui/lazylist/highlight.go b/internal/ui/lazylist/highlight.go new file mode 100644 index 0000000000000000000000000000000000000000..c5b885238771d57f28e4177684c7e24e1336a5b8 --- /dev/null +++ b/internal/ui/lazylist/highlight.go @@ -0,0 +1,289 @@ +package lazylist + +import ( + "image" + + "charm.land/lipgloss/v2" + uv "github.com/charmbracelet/ultraviolet" +) + +// DefaultHighlighter is the default highlighter function that applies inverse style. +var DefaultHighlighter Highlighter = func(s uv.Style) uv.Style { + s.Attrs |= uv.AttrReverse + return s +} + +// Highlighter represents a function that defines how to highlight text. +type Highlighter func(uv.Style) uv.Style + +// Highlight highlights a region of text within the given content and region. +func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string { + if startLine < 0 || startCol < 0 { + return content + } + + if highlighter == nil { + highlighter = DefaultHighlighter + } + + width, height := area.Dx(), area.Dy() + buf := uv.NewScreenBuffer(width, height) + styled := uv.NewStyledString(content) + styled.Draw(&buf, area) + + for y := startLine; y <= endLine && y < height; y++ { + if y >= buf.Height() { + break + } + + line := buf.Line(y) + + // Determine column range for this line + colStart := 0 + if y == startLine { + colStart = min(startCol, len(line)) + } + + colEnd := len(line) + if y == endLine { + colEnd = min(endCol, len(line)) + } + + // Track last non-empty position as we go + lastContentX := -1 + + // Single pass: check content and track last non-empty position + for x := colStart; x < colEnd; x++ { + cell := line.At(x) + if cell == nil { + continue + } + + // Update last content position if non-empty + if cell.Content != "" && cell.Content != " " { + lastContentX = x + } + } + + // Only apply highlight up to last content position + highlightEnd := colEnd + if lastContentX >= 0 { + highlightEnd = lastContentX + 1 + } else if lastContentX == -1 { + highlightEnd = colStart // No content on this line + } + + // Apply highlight style only to cells with content + for x := colStart; x < highlightEnd; x++ { + if !image.Pt(x, y).In(area) { + continue + } + cell := line.At(x) + cell.Style = highlighter(cell.Style) + } + } + + return buf.Render() +} + +// RenderWithHighlight renders content with optional focus styling and highlighting. +// This is a helper that combines common rendering logic for all items. +// The content parameter should be the raw rendered content before focus styling. +// The style parameter should come from CurrentStyle() and may be nil. +// func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string { +// // Apply focus/blur styling if configured +// rendered := content +// if style != nil { +// rendered = style.Render(rendered) +// } +// +// if !b.HasHighlight() { +// return rendered +// } +// +// height := lipgloss.Height(rendered) +// +// // Create temp buffer to draw content with highlighting +// tempBuf := uv.NewScreenBuffer(width, height) +// +// // Draw the rendered content to temp buffer +// styled := uv.NewStyledString(rendered) +// styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) +// +// // Apply highlighting if active +// b.ApplyHighlight(&tempBuf, width, height, style) +// +// return tempBuf.Render() +// } + +// ApplyHighlight applies highlighting to a screen buffer. +// This should be called after drawing content to the buffer. +// func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) { +// if b.highlightStartLine < 0 { +// return +// } +// +// var ( +// topMargin, topBorder, topPadding int +// rightMargin, rightBorder, rightPadding int +// bottomMargin, bottomBorder, bottomPadding int +// leftMargin, leftBorder, leftPadding int +// ) +// if style != nil { +// topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin() +// topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(), +// style.GetBorderRightSize(), +// style.GetBorderBottomSize(), +// style.GetBorderLeftSize() +// topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding() +// } +// +// slog.Info("Applying highlight", +// "highlightStartLine", b.highlightStartLine, +// "highlightStartCol", b.highlightStartCol, +// "highlightEndLine", b.highlightEndLine, +// "highlightEndCol", b.highlightEndCol, +// "width", width, +// "height", height, +// "margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin), +// "borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder), +// "paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding), +// ) +// +// // Calculate content area offsets +// contentArea := image.Rectangle{ +// Min: image.Point{ +// X: leftMargin + leftBorder + leftPadding, +// Y: topMargin + topBorder + topPadding, +// }, +// Max: image.Point{ +// X: width - (rightMargin + rightBorder + rightPadding), +// Y: height - (bottomMargin + bottomBorder + bottomPadding), +// }, +// } +// +// for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ { +// if y >= buf.Height() { +// break +// } +// +// line := buf.Line(y) +// +// // Determine column range for this line +// startCol := 0 +// if y == b.highlightStartLine { +// startCol = min(b.highlightStartCol, len(line)) +// } +// +// endCol := len(line) +// if y == b.highlightEndLine { +// endCol = min(b.highlightEndCol, len(line)) +// } +// +// // Track last non-empty position as we go +// lastContentX := -1 +// +// // Single pass: check content and track last non-empty position +// for x := startCol; x < endCol; x++ { +// cell := line.At(x) +// if cell == nil { +// continue +// } +// +// // Update last content position if non-empty +// if cell.Content != "" && cell.Content != " " { +// lastContentX = x +// } +// } +// +// // Only apply highlight up to last content position +// highlightEnd := endCol +// if lastContentX >= 0 { +// highlightEnd = lastContentX + 1 +// } else if lastContentX == -1 { +// highlightEnd = startCol // No content on this line +// } +// +// // Apply highlight style only to cells with content +// for x := startCol; x < highlightEnd; x++ { +// if !image.Pt(x, y).In(contentArea) { +// continue +// } +// cell := line.At(x) +// cell.Style = b.highlightStyle(cell.Style) +// } +// } +// } + +// ToHighlighter converts a [lipgloss.Style] to a [Highlighter]. +func ToHighlighter(lgStyle lipgloss.Style) Highlighter { + return func(uv.Style) uv.Style { + return ToStyle(lgStyle) + } +} + +// ToStyle converts an inline [lipgloss.Style] to a [uv.Style]. +func ToStyle(lgStyle lipgloss.Style) uv.Style { + var uvStyle uv.Style + + // Colors are already color.Color + uvStyle.Fg = lgStyle.GetForeground() + uvStyle.Bg = lgStyle.GetBackground() + + // Build attributes using bitwise OR + var attrs uint8 + + if lgStyle.GetBold() { + attrs |= uv.AttrBold + } + + if lgStyle.GetItalic() { + attrs |= uv.AttrItalic + } + + if lgStyle.GetUnderline() { + uvStyle.Underline = uv.UnderlineSingle + } + + if lgStyle.GetStrikethrough() { + attrs |= uv.AttrStrikethrough + } + + if lgStyle.GetFaint() { + attrs |= uv.AttrFaint + } + + if lgStyle.GetBlink() { + attrs |= uv.AttrBlink + } + + if lgStyle.GetReverse() { + attrs |= uv.AttrReverse + } + + uvStyle.Attrs = attrs + + return uvStyle +} + +// AdjustArea adjusts the given area rectangle by subtracting margins, borders, +// and padding from the style. +func AdjustArea(area image.Rectangle, style lipgloss.Style) image.Rectangle { + topMargin, rightMargin, bottomMargin, leftMargin := style.GetMargin() + topBorder, rightBorder, bottomBorder, leftBorder := style.GetBorderTopSize(), + style.GetBorderRightSize(), + style.GetBorderBottomSize(), + style.GetBorderLeftSize() + topPadding, rightPadding, bottomPadding, leftPadding := style.GetPadding() + + return image.Rectangle{ + Min: image.Point{ + X: area.Min.X + leftMargin + leftBorder + leftPadding, + Y: area.Min.Y + topMargin + topBorder + topPadding, + }, + Max: image.Point{ + X: area.Max.X - (rightMargin + rightBorder + rightPadding), + Y: area.Max.Y - (bottomMargin + bottomBorder + bottomPadding), + }, + } +} diff --git a/internal/ui/lazylist/item.go b/internal/ui/lazylist/item.go index 3ec31fbcf3d091e8d3438b2ecccfd467658b5f0f..7c6904d7ad2cd30aec0fab62b9238141343c4e9f 100644 --- a/internal/ui/lazylist/item.go +++ b/internal/ui/lazylist/item.go @@ -1,5 +1,7 @@ package lazylist +import "charm.land/lipgloss/v2" + // Item represents a single item in the lazy-loaded list. type Item interface { // Render returns the string representation of the item for the given @@ -7,24 +9,16 @@ type Item interface { Render(width int) string } -// Focusable represents an item that can gain or lose focus. -type Focusable interface { - // Focus sets the focus state of the item. - Focus() - - // Blur removes the focus state of the item. - Blur() - - // Focused returns whether the item is focused. - Focused() bool +// FocusStylable represents an item that can be styled based on focus state. +type FocusStylable interface { + // FocusStyle returns the style to apply when the item is focused. + FocusStyle() lipgloss.Style + // BlurStyle returns the style to apply when the item is unfocused. + BlurStyle() lipgloss.Style } -// Highlightable represents an item that can have a highlighted region. -type Highlightable interface { - // SetHighlight sets the highlight region (startLine, startCol) to (endLine, endCol). - // Use -1 for all values to clear highlighting. - SetHighlight(startLine, startCol, endLine, endCol int) - - // GetHighlight returns the current highlight region. - GetHighlight() (startLine, startCol, endLine, endCol int) +// HighlightStylable represents an item that can be styled for highlighted regions. +type HighlightStylable interface { + // HighlightStyle returns the style to apply for highlighted regions. + HighlightStyle() lipgloss.Style } diff --git a/internal/ui/lazylist/list.go b/internal/ui/lazylist/list.go index bbf8d970852645643b5b5a227aa671dd3e00161a..0cb6755681e7e56e7236bfca6ebf7dd2cfe41928 100644 --- a/internal/ui/lazylist/list.go +++ b/internal/ui/lazylist/list.go @@ -1,8 +1,11 @@ package lazylist import ( + "image" "log/slog" "strings" + + "charm.land/lipgloss/v2" ) // List represents a list of items that can be lazily rendered. A list is @@ -22,6 +25,16 @@ type List struct { focused bool selectedIdx int // The current selected index -1 means no selection + // Mouse state + mouseDown bool + mouseDownItem int // Item index where mouse was pressed + mouseDownX int // X position in item content (character offset) + mouseDownY int // Y position in item (line offset) + mouseDragItem int // Current item index being dragged over + mouseDragX int // Current X in item content + mouseDragY int // Current Y in item + lastHighlighted map[int]bool // Track which items were highlighted in last update + // Rendered content and cache renderedItems map[int]renderedItem @@ -44,6 +57,10 @@ func NewList(items ...Item) *List { l := new(List) l.items = items l.renderedItems = make(map[int]renderedItem) + l.selectedIdx = -1 + l.mouseDownItem = -1 + l.mouseDragItem = -1 + l.lastHighlighted = make(map[int]bool) return l } @@ -79,25 +96,98 @@ func (l *List) Len() int { // getItem renders (if needed) and returns the item at the given index. func (l *List) getItem(idx int) renderedItem { + return l.renderItem(idx, false) +} + +// renderItem renders (if needed) and returns the item at the given index. If +// process is true, it applies focus and highlight styling. +func (l *List) renderItem(idx int, process bool) renderedItem { if idx < 0 || idx >= len(l.items) { return renderedItem{} } - if item, ok := l.renderedItems[idx]; ok { - return item + var style lipgloss.Style + focusable, isFocusable := l.items[idx].(FocusStylable) + if isFocusable { + style = focusable.BlurStyle() + if l.focused && idx == l.selectedIdx { + style = focusable.FocusStyle() + } } - item := l.items[idx] - rendered := item.Render(l.width) - height := countLines(rendered) - // slog.Info("Rendered item", "idx", idx, "height", height) + ri, ok := l.renderedItems[idx] + if !ok { + item := l.items[idx] + rendered := item.Render(l.width - style.GetHorizontalFrameSize()) + height := countLines(rendered) + + ri = renderedItem{ + content: rendered, + height: height, + } + + l.renderedItems[idx] = ri + } + + if !process { + return ri + } + + // We apply highlighting before focus styling so that focus styling + // overrides highlight styles. + // Apply highlight if item supports it + if l.mouseDownItem >= 0 { + if highlightable, ok := l.items[idx].(HighlightStylable); ok { + startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange() + if idx >= startItemIdx && idx <= endItemIdx { + var sLine, sCol, eLine, eCol int + if idx == startItemIdx && idx == endItemIdx { + // Single item selection + sLine = startLine + sCol = startCol + eLine = endLine + eCol = endCol + } else if idx == startItemIdx { + // First item - from start position to end of item + sLine = startLine + sCol = startCol + eLine = ri.height - 1 + eCol = 9999 // 9999 = end of line + } else if idx == endItemIdx { + // Last item - from start of item to end position + sLine = 0 + sCol = 0 + eLine = endLine + eCol = endCol + } else { + // Middle item - fully highlighted + sLine = 0 + sCol = 0 + eLine = ri.height - 1 + eCol = 9999 + } - ri := renderedItem{ - content: rendered, - height: height, + // Apply offset for styling frame + contentArea := image.Rect(0, 0, l.width, ri.height) + + hiStyle := highlightable.HighlightStyle() + slog.Info("Highlighting item", "idx", idx, + "sLine", sLine, "sCol", sCol, + "eLine", eLine, "eCol", eCol, + ) + rendered := Highlight(ri.content, contentArea, sLine, sCol, eLine, eCol, ToHighlighter(hiStyle)) + ri.content = rendered + } + } } - l.renderedItems[idx] = ri + if isFocusable { + // Apply focus/blur styling if needed + rendered := style.Render(ri.content) + height := countLines(rendered) + ri.content = rendered + ri.height = height + } return ri } @@ -242,8 +332,6 @@ func (l *List) Render() string { return "" } - slog.Info("Render", "offsetIdx", l.offsetIdx, "offsetLine", l.offsetLine, "width", l.width, "height", l.height) - var lines []string currentIdx := l.offsetIdx currentOffset := l.offsetLine @@ -251,7 +339,7 @@ func (l *List) Render() string { linesNeeded := l.height for linesNeeded > 0 && currentIdx < len(l.items) { - item := l.getItem(currentIdx) + item := l.renderItem(currentIdx, true) itemLines := strings.Split(item.content, "\n") itemHeight := len(itemLines) @@ -321,11 +409,12 @@ func (l *List) Focus() { return } - item := l.items[l.selectedIdx] - if focusable, ok := item.(Focusable); ok { - focusable.Focus() - l.invalidateItem(l.selectedIdx) - } + // item := l.items[l.selectedIdx] + // if focusable, ok := item.(Focusable); ok { + // focusable.Focus() + // l.items[l.selectedIdx] = focusable.(Item) + // l.invalidateItem(l.selectedIdx) + // } } // Blur removes the focus state from the list. @@ -335,11 +424,12 @@ func (l *List) Blur() { return } - item := l.items[l.selectedIdx] - if focusable, ok := item.(Focusable); ok { - focusable.Blur() - l.invalidateItem(l.selectedIdx) - } + // item := l.items[l.selectedIdx] + // if focusable, ok := item.(Focusable); ok { + // focusable.Blur() + // l.items[l.selectedIdx] = focusable.(Item) + // l.invalidateItem(l.selectedIdx) + // } } // ScrollToTop scrolls the list to the top. @@ -467,15 +557,292 @@ func (l *List) SelectLastInView() { } // HandleMouseDown handles mouse down events at the given line in the viewport. -func (l *List) HandleMouseDown(x, y int) { +// x and y are viewport-relative coordinates (0,0 = top-left of visible area). +// Returns true if the event was handled. +func (l *List) HandleMouseDown(x, y int) bool { + if len(l.items) == 0 { + return false + } + + // Find which item was clicked + itemIdx, itemY := l.findItemAtY(x, y) + if itemIdx < 0 { + return false + } + + l.mouseDown = true + l.mouseDownItem = itemIdx + l.mouseDownX = x + l.mouseDownY = itemY + l.mouseDragItem = itemIdx + l.mouseDragX = x + l.mouseDragY = itemY + + // Select the clicked item + l.SetSelected(itemIdx) + + return true } // HandleMouseUp handles mouse up events at the given line in the viewport. -func (l *List) HandleMouseUp(x, y int) { +// Returns true if the event was handled. +func (l *List) HandleMouseUp(x, y int) bool { + if !l.mouseDown { + return false + } + + l.mouseDown = false + + return true } // HandleMouseDrag handles mouse drag events at the given line in the viewport. -func (l *List) HandleMouseDrag(x, y int) { +// x and y are viewport-relative coordinates. +// Returns true if the event was handled. +func (l *List) HandleMouseDrag(x, y int) bool { + if !l.mouseDown { + return false + } + + if len(l.items) == 0 { + return false + } + + // Find which item we're dragging over + itemIdx, itemY := l.findItemAtY(x, y) + if itemIdx < 0 { + return false + } + + l.mouseDragItem = itemIdx + l.mouseDragX = x + l.mouseDragY = itemY + + startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange() + + slog.Info("HandleMouseDrag", "mouseDownItem", l.mouseDownItem, + "mouseDragItem", l.mouseDragItem, + "startItemIdx", startItemIdx, + "endItemIdx", endItemIdx, + "startLine", startLine, + "startCol", startCol, + "endLine", endLine, + "endCol", endCol, + ) + + // for i := startItemIdx; i <= endItemIdx; i++ { + // item := l.getItem(i) + // itemHi, ok := l.items[i].(Highlightable) + // if ok { + // if i == startItemIdx && i == endItemIdx { + // // Single item selection + // itemHi.SetHighlight(startLine, startCol, endLine, endCol) + // } else if i == startItemIdx { + // // First item - from start position to end of item + // itemHi.SetHighlight(startLine, startCol, item.height-1, 9999) // 9999 = end of line + // } else if i == endItemIdx { + // // Last item - from start of item to end position + // itemHi.SetHighlight(0, 0, endLine, endCol) + // } else { + // // Middle item - fully highlighted + // itemHi.SetHighlight(0, 0, item.height-1, 9999) + // } + // + // // Invalidate item to re-render + // l.items[i] = itemHi.(Item) + // l.invalidateItem(i) + // } + // } + + // Update highlight if item supports it + // l.updateHighlight() + + return true +} + +// ClearHighlight clears any active text highlighting. +func (l *List) ClearHighlight() { + // for i, item := range l.renderedItems { + // if !item.highlighted { + // continue + // } + // if h, ok := l.items[i].(Highlightable); ok { + // h.SetHighlight(-1, -1, -1, -1) + // l.items[i] = h.(Item) + // l.invalidateItem(i) + // } + // } + l.mouseDownItem = -1 + l.mouseDragItem = -1 + l.lastHighlighted = make(map[int]bool) +} + +// findItemAtY finds the item at the given viewport y coordinate. +// Returns the item index and the y offset within that item. It returns -1, -1 +// if no item is found. +func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) { + if y < 0 || y >= l.height { + return -1, -1 + } + + // Walk through visible items to find which one contains this y + currentIdx := l.offsetIdx + currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden + + for currentIdx < len(l.items) && currentLine < l.height { + item := l.getItem(currentIdx) + itemEndLine := currentLine + item.height + + // Check if y is within this item's visible range + if y >= currentLine && y < itemEndLine { + // Found the item, calculate itemY (offset within the item) + itemY = y - currentLine + return currentIdx, itemY + } + + // Move to next item + currentLine = itemEndLine + if l.gap > 0 { + currentLine += l.gap + } + currentIdx++ + } + + return -1, -1 +} + +// getHighlightRange returns the current highlight range. +func (l *List) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) { + if l.mouseDownItem < 0 { + return -1, -1, -1, -1, -1, -1 + } + + downItemIdx := l.mouseDownItem + dragItemIdx := l.mouseDragItem + + // Determine selection direction + draggingDown := dragItemIdx > downItemIdx || + (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) || + (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX) + + if draggingDown { + // Normal forward selection + startItemIdx = downItemIdx + startLine = l.mouseDownY + startCol = l.mouseDownX + endItemIdx = dragItemIdx + endLine = l.mouseDragY + endCol = l.mouseDragX + } else { + // Backward selection (dragging up) + startItemIdx = dragItemIdx + startLine = l.mouseDragY + startCol = l.mouseDragX + endItemIdx = downItemIdx + endLine = l.mouseDownY + endCol = l.mouseDownX + } + + slog.Info("Apply highlight", + "startItemIdx", startItemIdx, + "endItemIdx", endItemIdx, + "startLine", startLine, + "startCol", startCol, + "endLine", endLine, + "endCol", endCol, + ) + + return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol +} + +// updateHighlight updates the highlight range for highlightable items. +// Supports highlighting across multiple items and respects drag direction. +func (l *List) updateHighlight() { + if l.mouseDownItem < 0 { + return + } + + // Get start and end item indices + downItemIdx := l.mouseDownItem + dragItemIdx := l.mouseDragItem + + // Determine selection direction + draggingDown := dragItemIdx > downItemIdx || + (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) || + (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX) + + // Determine actual start and end based on direction + var startItemIdx, endItemIdx int + var startLine, startCol, endLine, endCol int + + if draggingDown { + // Normal forward selection + startItemIdx = downItemIdx + endItemIdx = dragItemIdx + startLine = l.mouseDownY + startCol = l.mouseDownX + endLine = l.mouseDragY + endCol = l.mouseDragX + } else { + // Backward selection (dragging up) + startItemIdx = dragItemIdx + endItemIdx = downItemIdx + startLine = l.mouseDragY + startCol = l.mouseDragX + endLine = l.mouseDownY + endCol = l.mouseDownX + } + + slog.Info("Update highlight", "startItemIdx", startItemIdx, "endItemIdx", endItemIdx, + "startLine", startLine, "startCol", startCol, + "endLine", endLine, "endCol", endCol, + "draggingDown", draggingDown, + ) + + // Track newly highlighted items + // newHighlighted := make(map[int]bool) + + // Clear highlights on items that are no longer in range + // for i := range l.lastHighlighted { + // if i < startItemIdx || i > endItemIdx { + // if h, ok := l.items[i].(Highlightable); ok { + // h.SetHighlight(-1, -1, -1, -1) + // l.items[i] = h.(Item) + // l.invalidateItem(i) + // } + // } + // } + + // Highlight all items in range + // for idx := startItemIdx; idx <= endItemIdx; idx++ { + // item, ok := l.items[idx].(Highlightable) + // if !ok { + // continue + // } + // + // renderedItem := l.getItem(idx) + // + // if idx == startItemIdx && idx == endItemIdx { + // // Single item selection + // item.SetHighlight(startLine, startCol, endLine, endCol) + // } else if idx == startItemIdx { + // // First item - from start position to end of item + // item.SetHighlight(startLine, startCol, renderedItem.height-1, 9999) // 9999 = end of line + // } else if idx == endItemIdx { + // // Last item - from start of item to end position + // item.SetHighlight(0, 0, endLine, endCol) + // } else { + // // Middle item - fully highlighted + // item.SetHighlight(0, 0, renderedItem.height-1, 9999) + // } + // + // l.items[idx] = item.(Item) + // + // l.invalidateItem(idx) + // newHighlighted[idx] = true + // } + // + // l.lastHighlighted = newHighlighted } // countLines counts the number of lines in a string. diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 8872081b01794c5a3acad1d0057c6b4d49fea8da..b34b6e43aeb9294bf33a835bbc4c5ba786082e49 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -1,198 +1,11 @@ package model import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/lazylist" - "github.com/charmbracelet/crush/internal/ui/list" - "github.com/charmbracelet/crush/internal/ui/styles" uv "github.com/charmbracelet/ultraviolet" ) -// ChatAnimItem represents a chat animation item in the chat UI. -type ChatAnimItem struct { - list.BaseFocusable - anim *anim.Anim -} - -var ( - _ list.Item = (*ChatAnimItem)(nil) - _ list.Focusable = (*ChatAnimItem)(nil) -) - -// NewChatAnimItem creates a new instance of [ChatAnimItem]. -func NewChatAnimItem(a *anim.Anim) *ChatAnimItem { - m := new(ChatAnimItem) - return m -} - -// Init initializes the chat animation item. -func (c *ChatAnimItem) Init() tea.Cmd { - return c.anim.Init() -} - -// Step advances the animation by one step. -func (c *ChatAnimItem) Step() tea.Cmd { - return c.anim.Step() -} - -// SetLabel sets the label for the animation item. -func (c *ChatAnimItem) SetLabel(label string) { - c.anim.SetLabel(label) -} - -// Draw implements list.Item. -func (c *ChatAnimItem) Draw(scr uv.Screen, area uv.Rectangle) { - styled := uv.NewStyledString(c.anim.View()) - styled.Draw(scr, area) -} - -// Height implements list.Item. -func (c *ChatAnimItem) Height(int) int { - return 1 -} - -// ChatNoContentItem represents a chat item with no content. -type ChatNoContentItem struct { - *list.StringItem -} - -// NewChatNoContentItem creates a new instance of [ChatNoContentItem]. -func NewChatNoContentItem(t *styles.Styles) *ChatNoContentItem { - c := new(ChatNoContentItem) - c.StringItem = list.NewStringItem("No message content"). - WithFocusStyles(&t.Chat.Message.NoContent, &t.Chat.Message.NoContent) - return c -} - -// ChatMessageItem represents a chat message item in the chat UI. -type ChatMessageItem struct { - item list.Item - msg message.Message -} - -var ( - _ list.Item = (*ChatMessageItem)(nil) - _ list.Focusable = (*ChatMessageItem)(nil) - _ list.Highlightable = (*ChatMessageItem)(nil) -) - -// NewChatMessageItem creates a new instance of [ChatMessageItem]. -func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem { - c := new(ChatMessageItem) - - switch msg.Role { - case message.User: - item := list.NewMarkdownItem(msg.Content().String()). - WithFocusStyles(&t.Chat.Message.UserFocused, &t.Chat.Message.UserBlurred) - item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection)) - // TODO: Add attachments - c.item = item - default: - var thinkingContent string - content := msg.Content().String() - thinking := msg.IsThinking() - finished := msg.IsFinished() - finishedData := msg.FinishPart() - reasoningContent := msg.ReasoningContent() - reasoningThinking := strings.TrimSpace(reasoningContent.Thinking) - - if finished && content == "" && finishedData.Reason == message.FinishReasonError { - tag := t.Chat.Message.ErrorTag.Render("ERROR") - title := t.Chat.Message.ErrorTitle.Render(finishedData.Message) - details := t.Chat.Message.ErrorDetails.Render(finishedData.Details) - errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details) - - item := list.NewStringItem(errContent). - WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred) - - c.item = item - - return c - } - - if thinking || reasoningThinking != "" { - // TODO: animation item? - // TODO: thinking item - thinkingContent = reasoningThinking - } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled { - content = "*Canceled*" - } - - var parts []string - if thinkingContent != "" { - parts = append(parts, thinkingContent) - } - - if content != "" { - if len(parts) > 0 { - parts = append(parts, "") - } - parts = append(parts, content) - } - - item := list.NewMarkdownItem(strings.Join(parts, "\n")). - WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred) - item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection)) - - c.item = item - } - - return c -} - -// Draw implements list.Item. -func (c *ChatMessageItem) Draw(scr uv.Screen, area uv.Rectangle) { - c.item.Draw(scr, area) -} - -// Height implements list.Item. -func (c *ChatMessageItem) Height(width int) int { - return c.item.Height(width) -} - -// Blur implements list.Focusable. -func (c *ChatMessageItem) Blur() { - if blurable, ok := c.item.(list.Focusable); ok { - blurable.Blur() - } -} - -// Focus implements list.Focusable. -func (c *ChatMessageItem) Focus() { - if focusable, ok := c.item.(list.Focusable); ok { - focusable.Focus() - } -} - -// IsFocused implements list.Focusable. -func (c *ChatMessageItem) IsFocused() bool { - if focusable, ok := c.item.(list.Focusable); ok { - return focusable.IsFocused() - } - return false -} - -// GetHighlight implements list.Highlightable. -func (c *ChatMessageItem) GetHighlight() (startLine int, startCol int, endLine int, endCol int) { - if highlightable, ok := c.item.(list.Highlightable); ok { - return highlightable.GetHighlight() - } - return 0, 0, 0, 0 -} - -// SetHighlight implements list.Highlightable. -func (c *ChatMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) { - if highlightable, ok := c.item.(list.Highlightable); ok { - highlightable.SetHighlight(startLine, startCol, endLine, endCol) - } -} - // Chat represents the chat UI model that handles chat interactions and // messages. type Chat struct { @@ -239,9 +52,11 @@ func (m *Chat) PrependItems(items ...lazylist.Item) { // AppendMessages appends a new message item to the chat list. func (m *Chat) AppendMessages(msgs ...MessageItem) { - for _, msg := range msgs { - m.AppendItems(msg) + items := make([]lazylist.Item, len(msgs)) + for i, msg := range msgs { + items[i] = msg } + m.list.AppendItems(items...) } // AppendItems appends new items to the chat list. diff --git a/internal/ui/model/items.go b/internal/ui/model/items.go index 48bf79d2f84c0000cd0c8c7e07b9da44401997fc..7e5f732ca8e60e824b3bced67feab3667e3823af 100644 --- a/internal/ui/model/items.go +++ b/internal/ui/model/items.go @@ -2,6 +2,8 @@ package model import ( "fmt" + "image" + "log/slog" "path/filepath" "strings" "time" @@ -14,7 +16,6 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/lazylist" - "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/ui/toolrender" ) @@ -25,38 +26,33 @@ type Identifiable interface { } // MessageItem represents a [message.Message] item that can be displayed in the -// UI and be part of a [list.List] identifiable by a unique ID. +// UI and be part of a [lazylist.List] identifiable by a unique ID. type MessageItem interface { - list.Item - list.Focusable - list.Highlightable + lazylist.Item lazylist.Item Identifiable } // MessageContentItem represents rendered message content (text, markdown, errors, etc). type MessageContentItem struct { - list.BaseFocusable - list.BaseHighlightable id string content string + role message.MessageRole isMarkdown bool maxWidth int - cache map[int]string // Cache for rendered content at different widths sty *styles.Styles } // NewMessageContentItem creates a new message content item. -func NewMessageContentItem(id, content string, isMarkdown bool, sty *styles.Styles) *MessageContentItem { +func NewMessageContentItem(id, content string, role message.MessageRole, isMarkdown bool, sty *styles.Styles) *MessageContentItem { m := &MessageContentItem{ id: id, content: content, isMarkdown: isMarkdown, + role: role, maxWidth: 120, - cache: make(map[int]string), sty: sty, } - m.InitHighlight() return m } @@ -65,76 +61,38 @@ func (m *MessageContentItem) ID() string { return m.id } -// Height implements list.Item. -func (m *MessageContentItem) Height(width int) int { - // Calculate content width accounting for frame size - contentWidth := width - if style := m.CurrentStyle(); style != nil { - contentWidth -= style.GetHorizontalFrameSize() - } - - rendered := m.render(contentWidth) - - // Apply focus/blur styling if configured to get accurate height - if style := m.CurrentStyle(); style != nil { - rendered = style.Render(rendered) +// FocusStyle returns the focus style. +func (m *MessageContentItem) FocusStyle() lipgloss.Style { + if m.role == message.User { + return m.sty.Chat.Message.UserFocused } - - return strings.Count(rendered, "\n") + 1 + return m.sty.Chat.Message.AssistantFocused } -// Draw implements list.Item. -func (m *MessageContentItem) Draw(scr uv.Screen, area uv.Rectangle) { - width := area.Dx() - height := area.Dy() - - // Calculate content width accounting for frame size - contentWidth := width - style := m.CurrentStyle() - if style != nil { - contentWidth -= style.GetHorizontalFrameSize() - } - - rendered := m.render(contentWidth) - - // Apply focus/blur styling if configured - if style != nil { - rendered = style.Render(rendered) +// BlurStyle returns the blur style. +func (m *MessageContentItem) BlurStyle() lipgloss.Style { + if m.role == message.User { + return m.sty.Chat.Message.UserBlurred } - - // Create temp buffer to draw content with highlighting - tempBuf := uv.NewScreenBuffer(width, height) - - // Draw the rendered content to temp buffer - styled := uv.NewStyledString(rendered) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - // Apply highlighting if active - m.ApplyHighlight(&tempBuf, width, height, style) - - // Copy temp buffer to actual screen at the target area - tempBuf.Draw(scr, area) + return m.sty.Chat.Message.AssistantBlurred } -// Render implements lazylist.Item. -func (m *MessageContentItem) Render(width int) string { - return m.render(width) +// HighlightStyle returns the highlight style. +func (m *MessageContentItem) HighlightStyle() lipgloss.Style { + return m.sty.TextSelection } -// render renders the content at the given width, using cache if available. -func (m *MessageContentItem) render(width int) string { +// Render renders the content at the given width, using cache if available. +// +// It implements [lazylist.Item]. +func (m *MessageContentItem) Render(width int) string { + contentWidth := width // Cap width to maxWidth for markdown - cappedWidth := width + cappedWidth := contentWidth if m.isMarkdown { - cappedWidth = min(width, m.maxWidth) - } - - // Check cache first - if cached, ok := m.cache[cappedWidth]; ok { - return cached + cappedWidth = min(contentWidth, m.maxWidth) } - // Not cached - render now var rendered string if m.isMarkdown { renderer := common.MarkdownRenderer(m.sty, cappedWidth) @@ -148,30 +106,19 @@ func (m *MessageContentItem) render(width int) string { rendered = m.content } - // Cache the result - m.cache[cappedWidth] = rendered return rendered } -// SetHighlight implements list.Highlightable and extends BaseHighlightable. -func (m *MessageContentItem) SetHighlight(startLine, startCol, endLine, endCol int) { - m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) - // Clear cache when highlight changes - m.cache = make(map[int]string) -} - // ToolCallItem represents a rendered tool call with its header and content. type ToolCallItem struct { - list.BaseFocusable - list.BaseHighlightable + BaseFocusable + BaseHighlightable id string toolCall message.ToolCall toolResult message.ToolResult cancelled bool isNested bool maxWidth int - cache map[int]cachedToolRender // Cache for rendered content at different widths - cacheKey string // Key to invalidate cache when content changes sty *styles.Styles } @@ -190,8 +137,6 @@ func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.To cancelled: cancelled, isNested: isNested, maxWidth: 120, - cache: make(map[int]cachedToolRender), - cacheKey: generateCacheKey(toolCall, toolResult, cancelled), sty: sty, } t.InitHighlight() @@ -209,116 +154,59 @@ func (t *ToolCallItem) ID() string { return t.id } -// Height implements list.Item. -func (t *ToolCallItem) Height(width int) int { - // Calculate content width accounting for frame size - contentWidth := width - frameSize := 0 - if style := t.CurrentStyle(); style != nil { - frameSize = style.GetHorizontalFrameSize() - contentWidth -= frameSize - } - - cached := t.renderCached(contentWidth) - - // Add frame size to height if needed - height := cached.height - if frameSize > 0 { - // Frame can add to height (borders, padding) - if style := t.CurrentStyle(); style != nil { - // Quick render to get accurate height with frame - rendered := style.Render(cached.content) - height = strings.Count(rendered, "\n") + 1 - } +// FocusStyle returns the focus style. +func (t *ToolCallItem) FocusStyle() lipgloss.Style { + if t.focusStyle != nil { + return *t.focusStyle } - - return height + return lipgloss.Style{} } -// Render implements lazylist.Item. -func (t *ToolCallItem) Render(width int) string { - cached := t.renderCached(width) - return cached.content -} - -// Draw implements list.Item. -func (t *ToolCallItem) Draw(scr uv.Screen, area uv.Rectangle) { - width := area.Dx() - height := area.Dy() - - // Calculate content width accounting for frame size - contentWidth := width - style := t.CurrentStyle() - if style != nil { - contentWidth -= style.GetHorizontalFrameSize() +// BlurStyle returns the blur style. +func (t *ToolCallItem) BlurStyle() lipgloss.Style { + if t.blurStyle != nil { + return *t.blurStyle } - - cached := t.renderCached(contentWidth) - rendered := cached.content - - if style != nil { - rendered = style.Render(rendered) - } - - tempBuf := uv.NewScreenBuffer(width, height) - styled := uv.NewStyledString(rendered) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - t.ApplyHighlight(&tempBuf, width, height, style) - tempBuf.Draw(scr, area) + return lipgloss.Style{} } -// renderCached renders the tool call at the given width with caching. -func (t *ToolCallItem) renderCached(width int) cachedToolRender { - cappedWidth := min(width, t.maxWidth) - - // Check if we have a valid cache entry - if cached, ok := t.cache[cappedWidth]; ok { - return cached - } +// HighlightStyle returns the highlight style. +func (t *ToolCallItem) HighlightStyle() lipgloss.Style { + return t.sty.TextSelection +} +// Render implements lazylist.Item. +func (t *ToolCallItem) Render(width int) string { // Render the tool call ctx := &toolrender.RenderContext{ Call: t.toolCall, Result: t.toolResult, Cancelled: t.cancelled, IsNested: t.isNested, - Width: cappedWidth, + Width: width, Styles: t.sty, } rendered := toolrender.Render(ctx) - height := strings.Count(rendered, "\n") + 1 + return rendered - cached := cachedToolRender{ - content: rendered, - height: height, - } - t.cache[cappedWidth] = cached - return cached + // return t.RenderWithHighlight(rendered, width, style) } // SetHighlight implements list.Highlightable. func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) { t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) - // Clear cache when highlight changes - t.cache = make(map[int]cachedToolRender) } // UpdateResult updates the tool result and invalidates the cache if needed. func (t *ToolCallItem) UpdateResult(result message.ToolResult) { - newKey := generateCacheKey(t.toolCall, result, t.cancelled) - if newKey != t.cacheKey { - t.toolResult = result - t.cacheKey = newKey - t.cache = make(map[int]cachedToolRender) - } + t.toolResult = result } // AttachmentItem represents a file attachment in a user message. type AttachmentItem struct { - list.BaseFocusable - list.BaseHighlightable + BaseFocusable + BaseHighlightable id string filename string path string @@ -342,33 +230,29 @@ func (a *AttachmentItem) ID() string { return a.id } -// Height implements list.Item. -func (a *AttachmentItem) Height(width int) int { - return 1 +// FocusStyle returns the focus style. +func (a *AttachmentItem) FocusStyle() lipgloss.Style { + if a.focusStyle != nil { + return *a.focusStyle + } + return lipgloss.Style{} } -// Render implements lazylist.Item. -func (a *AttachmentItem) Render(width int) string { - const maxFilenameWidth = 10 - return a.sty.Chat.Message.Attachment.Render(fmt.Sprintf( - " %s %s ", - styles.DocumentIcon, - ansi.Truncate(a.filename, maxFilenameWidth, "..."), - )) +// BlurStyle returns the blur style. +func (a *AttachmentItem) BlurStyle() lipgloss.Style { + if a.blurStyle != nil { + return *a.blurStyle + } + return lipgloss.Style{} } -// Draw implements list.Item. -func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) { - width := area.Dx() - height := area.Dy() - - // Calculate content width accounting for frame size - contentWidth := width - style := a.CurrentStyle() - if style != nil { - contentWidth -= style.GetHorizontalFrameSize() - } +// HighlightStyle returns the highlight style. +func (a *AttachmentItem) HighlightStyle() lipgloss.Style { + return a.sty.TextSelection +} +// Render implements lazylist.Item. +func (a *AttachmentItem) Render(width int) string { const maxFilenameWidth = 10 content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf( " %s %s ", @@ -376,28 +260,18 @@ func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) { ansi.Truncate(a.filename, maxFilenameWidth, "..."), )) - if style != nil { - content = style.Render(content) - } + return content - tempBuf := uv.NewScreenBuffer(width, height) - styled := uv.NewStyledString(content) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - a.ApplyHighlight(&tempBuf, width, height, style) - tempBuf.Draw(scr, area) + // return a.RenderWithHighlight(content, width, a.CurrentStyle()) } // ThinkingItem represents thinking/reasoning content in assistant messages. type ThinkingItem struct { - list.BaseFocusable - list.BaseHighlightable id string thinking string duration time.Duration finished bool maxWidth int - cache map[int]string sty *styles.Styles } @@ -409,10 +283,8 @@ func NewThinkingItem(id, thinking string, duration time.Duration, finished bool, duration: duration, finished: finished, maxWidth: 120, - cache: make(map[int]string), sty: sty, } - t.InitHighlight() return t } @@ -421,57 +293,25 @@ func (t *ThinkingItem) ID() string { return t.id } -// Height implements list.Item. -func (t *ThinkingItem) Height(width int) int { - // Calculate content width accounting for frame size - contentWidth := width - if style := t.CurrentStyle(); style != nil { - contentWidth -= style.GetHorizontalFrameSize() - } - - rendered := t.render(contentWidth) - return strings.Count(rendered, "\n") + 1 +// FocusStyle returns the focus style. +func (t *ThinkingItem) FocusStyle() lipgloss.Style { + return t.sty.Chat.Message.AssistantFocused } -// Render implements lazylist.Item. -func (t *ThinkingItem) Render(width int) string { - return t.render(width) +// BlurStyle returns the blur style. +func (t *ThinkingItem) BlurStyle() lipgloss.Style { + return t.sty.Chat.Message.AssistantBlurred } -// Draw implements list.Item. -func (t *ThinkingItem) Draw(scr uv.Screen, area uv.Rectangle) { - width := area.Dx() - height := area.Dy() - - // Calculate content width accounting for frame size - contentWidth := width - style := t.CurrentStyle() - if style != nil { - contentWidth -= style.GetHorizontalFrameSize() - } - - rendered := t.render(contentWidth) - - if style != nil { - rendered = style.Render(rendered) - } - - tempBuf := uv.NewScreenBuffer(width, height) - styled := uv.NewStyledString(rendered) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - t.ApplyHighlight(&tempBuf, width, height, style) - tempBuf.Draw(scr, area) +// HighlightStyle returns the highlight style. +func (t *ThinkingItem) HighlightStyle() lipgloss.Style { + return t.sty.TextSelection } -// render renders the thinking content. -func (t *ThinkingItem) render(width int) string { +// Render implements lazylist.Item. +func (t *ThinkingItem) Render(width int) string { cappedWidth := min(width, t.maxWidth) - if cached, ok := t.cache[cappedWidth]; ok { - return cached - } - renderer := common.PlainMarkdownRenderer(cappedWidth - 1) rendered, err := renderer.Render(t.thinking) if err != nil { @@ -501,20 +341,11 @@ func (t *ThinkingItem) render(width int) string { result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent) - t.cache[cappedWidth] = result return result } -// SetHighlight implements list.Highlightable. -func (t *ThinkingItem) SetHighlight(startLine, startCol, endLine, endCol int) { - t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) - t.cache = make(map[int]string) -} - // SectionHeaderItem represents a section header (e.g., assistant info). type SectionHeaderItem struct { - list.BaseFocusable - list.BaseHighlightable id string modelName string duration time.Duration @@ -531,7 +362,6 @@ func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *sty isSectionHeader: true, sty: sty, } - s.InitHighlight() return s } @@ -545,49 +375,25 @@ func (s *SectionHeaderItem) IsSectionHeader() bool { return s.isSectionHeader } -// Height implements list.Item. -func (s *SectionHeaderItem) Height(width int) int { - return 1 +// FocusStyle returns the focus style. +func (s *SectionHeaderItem) FocusStyle() lipgloss.Style { + return s.sty.Chat.Message.AssistantFocused +} + +// BlurStyle returns the blur style. +func (s *SectionHeaderItem) BlurStyle() lipgloss.Style { + return s.sty.Chat.Message.AssistantBlurred } // Render implements lazylist.Item. func (s *SectionHeaderItem) Render(width int) string { - return s.sty.Chat.Message.SectionHeader.Render(fmt.Sprintf("%s %s %s", + content := s.sty.Chat.Message.SectionHeader.Render(fmt.Sprintf("%s %s %s", s.sty.Subtle.Render(styles.ModelIcon), s.sty.Muted.Render(s.modelName), s.sty.Subtle.Render(s.duration.String()), )) -} - -// Draw implements list.Item. -func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) { - width := area.Dx() - height := area.Dy() - - // Calculate content width accounting for frame size - contentWidth := width - style := s.CurrentStyle() - if style != nil { - contentWidth -= style.GetHorizontalFrameSize() - } - - infoMsg := s.sty.Subtle.Render(s.duration.String()) - icon := s.sty.Subtle.Render(styles.ModelIcon) - modelFormatted := s.sty.Muted.Render(s.modelName) - content := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg) - - content = s.sty.Chat.Message.SectionHeader.Render(content) - - if style != nil { - content = style.Render(content) - } - tempBuf := uv.NewScreenBuffer(width, height) - styled := uv.NewStyledString(content) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - s.ApplyHighlight(&tempBuf, width, height, style) - tempBuf.Draw(scr, area) + return content } // GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns @@ -595,8 +401,7 @@ func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) { // // For assistant messages with tool calls, pass a toolResults map to link results. // Use BuildToolResultMap to create this map from all messages in a session. -func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { - sty := styles.DefaultStyles() +func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { var items []MessageItem // Skip tool result messages - they're displayed inline with tool calls @@ -622,10 +427,10 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe item := NewMessageContentItem( fmt.Sprintf("%s-content", msg.ID), content, + msg.Role, true, // User messages are markdown - &sty, + sty, ) - item.SetFocusStyles(&focusStyle, &blurStyle) items = append(items, item) } @@ -636,8 +441,9 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe fmt.Sprintf("%s-attachment-%d", msg.ID, i), filename, attachment.Path, - &sty, + sty, ) + item.SetHighlightStyle(ToStyler(sty.TextSelection)) item.SetFocusStyles(&focusStyle, &blurStyle) items = append(items, item) } @@ -666,7 +472,7 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe fmt.Sprintf("%s-header", msg.ID), modelName, duration, - &sty, + sty, ) items = append(items, header) } @@ -684,9 +490,8 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe reasoning.Thinking, duration, reasoning.FinishedAt > 0, - &sty, + sty, ) - item.SetFocusStyles(&focusStyle, &blurStyle) items = append(items, item) } @@ -703,10 +508,10 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe item := NewMessageContentItem( fmt.Sprintf("%s-content", msg.ID), "*Canceled*", + msg.Role, true, - &sty, + sty, ) - item.SetFocusStyles(&focusStyle, &blurStyle) items = append(items, item) case message.FinishReasonError: // Render error @@ -719,20 +524,20 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe item := NewMessageContentItem( fmt.Sprintf("%s-error", msg.ID), errorContent, + msg.Role, false, - &sty, + sty, ) - item.SetFocusStyles(&focusStyle, &blurStyle) items = append(items, item) } } else if content != "" { item := NewMessageContentItem( fmt.Sprintf("%s-content", msg.ID), content, + msg.Role, true, // Assistant messages are markdown - &sty, + sty, ) - item.SetFocusStyles(&focusStyle, &blurStyle) items = append(items, item) } @@ -757,9 +562,11 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe result, false, // cancelled state would need to be tracked separately false, // nested state would be detected from tool results - &sty, + sty, ) + item.SetHighlightStyle(ToStyler(sty.TextSelection)) + // Tool calls use muted style with optional focus border item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred) @@ -788,3 +595,299 @@ func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResu } return resultMap } + +// BaseFocusable provides common focus state and styling for items. +// Embed this type to add focus behavior to any item. +type BaseFocusable struct { + focused bool + focusStyle *lipgloss.Style + blurStyle *lipgloss.Style +} + +// Focus implements Focusable interface. +func (b *BaseFocusable) Focus(width int, content string) string { + if b.focusStyle != nil { + return b.focusStyle.Render(content) + } + return content +} + +// Blur implements Focusable interface. +func (b *BaseFocusable) Blur(width int, content string) string { + if b.blurStyle != nil { + return b.blurStyle.Render(content) + } + return content +} + +// Focus implements Focusable interface. +// func (b *BaseFocusable) Focus() { +// b.focused = true +// } + +// Blur implements Focusable interface. +// func (b *BaseFocusable) Blur() { +// b.focused = false +// } + +// Focused implements Focusable interface. +func (b *BaseFocusable) Focused() bool { + return b.focused +} + +// HasFocusStyles returns true if both focus and blur styles are configured. +func (b *BaseFocusable) HasFocusStyles() bool { + return b.focusStyle != nil && b.blurStyle != nil +} + +// CurrentStyle returns the current style based on focus state. +// Returns nil if no styles are configured, or if the current state's style is nil. +func (b *BaseFocusable) CurrentStyle() *lipgloss.Style { + if b.focused { + return b.focusStyle + } + return b.blurStyle +} + +// SetFocusStyles sets the focus and blur styles. +func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) { + b.focusStyle = focusStyle + b.blurStyle = blurStyle +} + +// CellStyler defines a function that styles a [uv.Style]. +type CellStyler func(uv.Style) uv.Style + +// BaseHighlightable provides common highlight state for items. +// Embed this type to add highlight behavior to any item. +type BaseHighlightable struct { + highlightStartLine int + highlightStartCol int + highlightEndLine int + highlightEndCol int + highlightStyle CellStyler +} + +// SetHighlight implements Highlightable interface. +func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) { + b.highlightStartLine = startLine + b.highlightStartCol = startCol + b.highlightEndLine = endLine + b.highlightEndCol = endCol +} + +// GetHighlight implements Highlightable interface. +func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) { + return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol +} + +// HasHighlight returns true if a highlight region is set. +func (b *BaseHighlightable) HasHighlight() bool { + return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 || + b.highlightEndLine >= 0 || b.highlightEndCol >= 0 +} + +// SetHighlightStyle sets the style function used for highlighting. +func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) { + b.highlightStyle = style +} + +// GetHighlightStyle returns the current highlight style function. +func (b *BaseHighlightable) GetHighlightStyle() CellStyler { + return b.highlightStyle +} + +// InitHighlight initializes the highlight fields with default values. +func (b *BaseHighlightable) InitHighlight() { + b.highlightStartLine = -1 + b.highlightStartCol = -1 + b.highlightEndLine = -1 + b.highlightEndCol = -1 + b.highlightStyle = ToStyler(lipgloss.NewStyle().Reverse(true)) +} + +// Highlight implements Highlightable interface. +func (b *BaseHighlightable) Highlight(width int, content string, startLine, startCol, endLine, endCol int) string { + b.SetHighlight(startLine, startCol, endLine, endCol) + return b.RenderWithHighlight(content, width, nil) +} + +// RenderWithHighlight renders content with optional focus styling and highlighting. +// This is a helper that combines common rendering logic for all items. +// The content parameter should be the raw rendered content before focus styling. +// The style parameter should come from CurrentStyle() and may be nil. +func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string { + // Apply focus/blur styling if configured + rendered := content + if style != nil { + rendered = style.Render(rendered) + } + + if !b.HasHighlight() { + return rendered + } + + height := lipgloss.Height(rendered) + + // Create temp buffer to draw content with highlighting + tempBuf := uv.NewScreenBuffer(width, height) + + // Draw the rendered content to temp buffer + styled := uv.NewStyledString(rendered) + styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) + + // Apply highlighting if active + b.ApplyHighlight(&tempBuf, width, height, style) + + return tempBuf.Render() +} + +// ApplyHighlight applies highlighting to a screen buffer. +// This should be called after drawing content to the buffer. +func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) { + if b.highlightStartLine < 0 { + return + } + + var ( + topMargin, topBorder, topPadding int + rightMargin, rightBorder, rightPadding int + bottomMargin, bottomBorder, bottomPadding int + leftMargin, leftBorder, leftPadding int + ) + if style != nil { + topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin() + topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(), + style.GetBorderRightSize(), + style.GetBorderBottomSize(), + style.GetBorderLeftSize() + topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding() + } + + slog.Info("Applying highlight", + "highlightStartLine", b.highlightStartLine, + "highlightStartCol", b.highlightStartCol, + "highlightEndLine", b.highlightEndLine, + "highlightEndCol", b.highlightEndCol, + "width", width, + "height", height, + "margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin), + "borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder), + "paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding), + ) + + // Calculate content area offsets + contentArea := image.Rectangle{ + Min: image.Point{ + X: leftMargin + leftBorder + leftPadding, + Y: topMargin + topBorder + topPadding, + }, + Max: image.Point{ + X: width - (rightMargin + rightBorder + rightPadding), + Y: height - (bottomMargin + bottomBorder + bottomPadding), + }, + } + + for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ { + if y >= buf.Height() { + break + } + + line := buf.Line(y) + + // Determine column range for this line + startCol := 0 + if y == b.highlightStartLine { + startCol = min(b.highlightStartCol, len(line)) + } + + endCol := len(line) + if y == b.highlightEndLine { + endCol = min(b.highlightEndCol, len(line)) + } + + // Track last non-empty position as we go + lastContentX := -1 + + // Single pass: check content and track last non-empty position + for x := startCol; x < endCol; x++ { + cell := line.At(x) + if cell == nil { + continue + } + + // Update last content position if non-empty + if cell.Content != "" && cell.Content != " " { + lastContentX = x + } + } + + // Only apply highlight up to last content position + highlightEnd := endCol + if lastContentX >= 0 { + highlightEnd = lastContentX + 1 + } else if lastContentX == -1 { + highlightEnd = startCol // No content on this line + } + + // Apply highlight style only to cells with content + for x := startCol; x < highlightEnd; x++ { + if !image.Pt(x, y).In(contentArea) { + continue + } + cell := line.At(x) + cell.Style = b.highlightStyle(cell.Style) + } + } +} + +// ToStyler converts a [lipgloss.Style] to a [CellStyler]. +func ToStyler(lgStyle lipgloss.Style) CellStyler { + return func(uv.Style) uv.Style { + return ToStyle(lgStyle) + } +} + +// ToStyle converts an inline [lipgloss.Style] to a [uv.Style]. +func ToStyle(lgStyle lipgloss.Style) uv.Style { + var uvStyle uv.Style + + // Colors are already color.Color + uvStyle.Fg = lgStyle.GetForeground() + uvStyle.Bg = lgStyle.GetBackground() + + // Build attributes using bitwise OR + var attrs uint8 + + if lgStyle.GetBold() { + attrs |= uv.AttrBold + } + + if lgStyle.GetItalic() { + attrs |= uv.AttrItalic + } + + if lgStyle.GetUnderline() { + uvStyle.Underline = uv.UnderlineSingle + } + + if lgStyle.GetStrikethrough() { + attrs |= uv.AttrStrikethrough + } + + if lgStyle.GetFaint() { + attrs |= uv.AttrFaint + } + + if lgStyle.GetBlink() { + attrs |= uv.AttrBlink + } + + if lgStyle.GetReverse() { + attrs |= uv.AttrReverse + } + + uvStyle.Attrs = attrs + + return uvStyle +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 5e24004d84a56d8c07bad623e59c7c2df6cdb31b..6d58ce3b1b6ec5172c0ec3cfe0f1be239874ed60 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -7,7 +7,6 @@ import ( "os" "slices" "strings" - "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" @@ -171,8 +170,8 @@ func (m *UI) Init() tea.Cmd { allSessions, _ := m.com.App.Sessions.List(context.Background()) if len(allSessions) > 0 { cmds = append(cmds, func() tea.Msg { - time.Sleep(2 * time.Second) - return m.loadSession(allSessions[1].ID)() + // time.Sleep(2 * time.Second) + return m.loadSession(allSessions[0].ID)() }) } return tea.Batch(cmds...) @@ -207,7 +206,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Add messages to chat with linked tool results items := make([]MessageItem, 0, len(msgs)*2) for _, msg := range msgPtrs { - items = append(items, GetMessageItems(msg, toolResultMap)...) + items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...) } m.chat.AppendMessages(items...) @@ -247,7 +246,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseClickMsg: switch m.state { case uiChat: - m.chat.HandleMouseDown(msg.X, msg.Y) + x, y := msg.X, msg.Y + // Adjust for chat area position + x -= m.layout.main.Min.X + y -= m.layout.main.Min.Y + m.chat.HandleMouseDown(x, y) } case tea.MouseMotionMsg: @@ -258,13 +261,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if msg.Y >= m.chat.Height()-1 { m.chat.ScrollBy(1) } - m.chat.HandleMouseDrag(msg.X, msg.Y) + + x, y := msg.X, msg.Y + // Adjust for chat area position + x -= m.layout.main.Min.X + y -= m.layout.main.Min.Y + m.chat.HandleMouseDrag(x, y) } case tea.MouseReleaseMsg: switch m.state { case uiChat: - m.chat.HandleMouseUp(msg.X, msg.Y) + x, y := msg.X, msg.Y + // Adjust for chat area position + x -= m.layout.main.Min.X + y -= m.layout.main.Min.Y + m.chat.HandleMouseUp(x, y) } case tea.MouseWheelMsg: switch m.state { From e2a586ca40e3fa0f9c4c3d19ec7f80d54c61057f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 9 Dec 2025 18:17:07 -0500 Subject: [PATCH 037/335] fix(ui): correct scrolling up behavior in lazy list --- internal/ui/lazylist/list.go | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/internal/ui/lazylist/list.go b/internal/ui/lazylist/list.go index 0cb6755681e7e56e7236bfca6ebf7dd2cfe41928..e5478b85ac2b46f01ec79b1433cabad2d5062163 100644 --- a/internal/ui/lazylist/list.go +++ b/internal/ui/lazylist/list.go @@ -259,25 +259,14 @@ func (l *List) ScrollBy(lines int) { } } else if lines < 0 { // Scroll up - // Calculate from offset how many items needed to fill the viewport - // This is needed to know when to stop scrolling up - var totalLines int - var firstItemIdx int - for i := l.offsetIdx; i >= 0; i-- { - item := l.getItem(i) - totalLines += item.height - if l.gap > 0 && i < l.offsetIdx { - totalLines += l.gap - } - if totalLines >= l.height { - firstItemIdx = i + l.offsetLine += lines // lines is negative + for l.offsetLine < 0 { + if l.offsetIdx <= 0 { + // Reached top + l.ScrollToTop() break } - } - // Now scroll up by lines - l.offsetLine += lines // lines is negative - for l.offsetIdx > firstItemIdx && l.offsetLine < 0 { // Move to previous item l.offsetIdx-- prevItem := l.getItem(l.offsetIdx) @@ -287,10 +276,6 @@ func (l *List) ScrollBy(lines int) { } l.offsetLine += totalHeight } - - if l.offsetLine < 0 { - l.offsetLine = 0 - } } } From 065f33999124673c5a9ab6c2e3de6f0393c31520 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 10 Dec 2025 11:42:49 -0500 Subject: [PATCH 038/335] fix(ui): dry highlighting items in lazylist --- internal/ui/lazylist/list.go | 262 +++++++---------------------------- internal/ui/model/ui.go | 8 ++ 2 files changed, 57 insertions(+), 213 deletions(-) diff --git a/internal/ui/lazylist/list.go b/internal/ui/lazylist/list.go index e5478b85ac2b46f01ec79b1433cabad2d5062163..423426ed256ea28a195aa7eacdbbeb6dc377d61c 100644 --- a/internal/ui/lazylist/list.go +++ b/internal/ui/lazylist/list.go @@ -2,7 +2,6 @@ package lazylist import ( "image" - "log/slog" "strings" "charm.land/lipgloss/v2" @@ -99,6 +98,49 @@ func (l *List) getItem(idx int) renderedItem { return l.renderItem(idx, false) } +// applyHighlight applies highlighting to the given rendered item. +func (l *List) applyHighlight(idx int, ri *renderedItem) { + // Apply highlight if item supports it + if highlightable, ok := l.items[idx].(HighlightStylable); ok { + startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange() + if idx >= startItemIdx && idx <= endItemIdx { + var sLine, sCol, eLine, eCol int + if idx == startItemIdx && idx == endItemIdx { + // Single item selection + sLine = startLine + sCol = startCol + eLine = endLine + eCol = endCol + } else if idx == startItemIdx { + // First item - from start position to end of item + sLine = startLine + sCol = startCol + eLine = ri.height - 1 + eCol = 9999 // 9999 = end of line + } else if idx == endItemIdx { + // Last item - from start of item to end position + sLine = 0 + sCol = 0 + eLine = endLine + eCol = endCol + } else { + // Middle item - fully highlighted + sLine = 0 + sCol = 0 + eLine = ri.height - 1 + eCol = 9999 + } + + // Apply offset for styling frame + contentArea := image.Rect(0, 0, l.width, ri.height) + + hiStyle := highlightable.HighlightStyle() + rendered := Highlight(ri.content, contentArea, sLine, sCol, eLine, eCol, ToHighlighter(hiStyle)) + ri.content = rendered + } + } +} + // renderItem renders (if needed) and returns the item at the given index. If // process is true, it applies focus and highlight styling. func (l *List) renderItem(idx int, process bool) renderedItem { @@ -130,55 +172,17 @@ func (l *List) renderItem(idx int, process bool) renderedItem { } if !process { + // Simply return cached rendered item with frame size applied + if vfs := style.GetVerticalFrameSize(); vfs > 0 { + ri.height += vfs + } return ri } // We apply highlighting before focus styling so that focus styling // overrides highlight styles. - // Apply highlight if item supports it if l.mouseDownItem >= 0 { - if highlightable, ok := l.items[idx].(HighlightStylable); ok { - startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange() - if idx >= startItemIdx && idx <= endItemIdx { - var sLine, sCol, eLine, eCol int - if idx == startItemIdx && idx == endItemIdx { - // Single item selection - sLine = startLine - sCol = startCol - eLine = endLine - eCol = endCol - } else if idx == startItemIdx { - // First item - from start position to end of item - sLine = startLine - sCol = startCol - eLine = ri.height - 1 - eCol = 9999 // 9999 = end of line - } else if idx == endItemIdx { - // Last item - from start of item to end position - sLine = 0 - sCol = 0 - eLine = endLine - eCol = endCol - } else { - // Middle item - fully highlighted - sLine = 0 - sCol = 0 - eLine = ri.height - 1 - eCol = 9999 - } - - // Apply offset for styling frame - contentArea := image.Rect(0, 0, l.width, ri.height) - - hiStyle := highlightable.HighlightStyle() - slog.Info("Highlighting item", "idx", idx, - "sLine", sLine, "sCol", sCol, - "eLine", eLine, "eCol", eCol, - ) - rendered := Highlight(ri.content, contentArea, sLine, sCol, eLine, eCol, ToHighlighter(hiStyle)) - ri.content = rendered - } - } + l.applyHighlight(idx, &ri) } if isFocusable { @@ -344,7 +348,7 @@ func (l *List) Render() string { gapOffset := currentOffset - itemHeight gapRemaining := l.gap - gapOffset if gapRemaining > 0 { - for i := 0; i < gapRemaining; i++ { + for range gapRemaining { lines = append(lines, "") } } @@ -390,31 +394,11 @@ func (l *List) AppendItems(items ...Item) { // Focus sets the focus state of the list. func (l *List) Focus() { l.focused = true - if l.selectedIdx < 0 || l.selectedIdx > len(l.items)-1 { - return - } - - // item := l.items[l.selectedIdx] - // if focusable, ok := item.(Focusable); ok { - // focusable.Focus() - // l.items[l.selectedIdx] = focusable.(Item) - // l.invalidateItem(l.selectedIdx) - // } } // Blur removes the focus state from the list. func (l *List) Blur() { l.focused = false - if l.selectedIdx < 0 || l.selectedIdx > len(l.items)-1 { - return - } - - // item := l.items[l.selectedIdx] - // if focusable, ok := item.(Focusable); ok { - // focusable.Blur() - // l.items[l.selectedIdx] = focusable.(Item) - // l.invalidateItem(l.selectedIdx) - // } } // ScrollToTop scrolls the list to the top. @@ -603,60 +587,11 @@ func (l *List) HandleMouseDrag(x, y int) bool { l.mouseDragX = x l.mouseDragY = itemY - startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange() - - slog.Info("HandleMouseDrag", "mouseDownItem", l.mouseDownItem, - "mouseDragItem", l.mouseDragItem, - "startItemIdx", startItemIdx, - "endItemIdx", endItemIdx, - "startLine", startLine, - "startCol", startCol, - "endLine", endLine, - "endCol", endCol, - ) - - // for i := startItemIdx; i <= endItemIdx; i++ { - // item := l.getItem(i) - // itemHi, ok := l.items[i].(Highlightable) - // if ok { - // if i == startItemIdx && i == endItemIdx { - // // Single item selection - // itemHi.SetHighlight(startLine, startCol, endLine, endCol) - // } else if i == startItemIdx { - // // First item - from start position to end of item - // itemHi.SetHighlight(startLine, startCol, item.height-1, 9999) // 9999 = end of line - // } else if i == endItemIdx { - // // Last item - from start of item to end position - // itemHi.SetHighlight(0, 0, endLine, endCol) - // } else { - // // Middle item - fully highlighted - // itemHi.SetHighlight(0, 0, item.height-1, 9999) - // } - // - // // Invalidate item to re-render - // l.items[i] = itemHi.(Item) - // l.invalidateItem(i) - // } - // } - - // Update highlight if item supports it - // l.updateHighlight() - return true } // ClearHighlight clears any active text highlighting. func (l *List) ClearHighlight() { - // for i, item := range l.renderedItems { - // if !item.highlighted { - // continue - // } - // if h, ok := l.items[i].(Highlightable); ok { - // h.SetHighlight(-1, -1, -1, -1) - // l.items[i] = h.(Item) - // l.invalidateItem(i) - // } - // } l.mouseDownItem = -1 l.mouseDragItem = -1 l.lastHighlighted = make(map[int]bool) @@ -728,108 +663,9 @@ func (l *List) getHighlightRange() (startItemIdx, startLine, startCol, endItemId endCol = l.mouseDownX } - slog.Info("Apply highlight", - "startItemIdx", startItemIdx, - "endItemIdx", endItemIdx, - "startLine", startLine, - "startCol", startCol, - "endLine", endLine, - "endCol", endCol, - ) - return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol } -// updateHighlight updates the highlight range for highlightable items. -// Supports highlighting across multiple items and respects drag direction. -func (l *List) updateHighlight() { - if l.mouseDownItem < 0 { - return - } - - // Get start and end item indices - downItemIdx := l.mouseDownItem - dragItemIdx := l.mouseDragItem - - // Determine selection direction - draggingDown := dragItemIdx > downItemIdx || - (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) || - (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX) - - // Determine actual start and end based on direction - var startItemIdx, endItemIdx int - var startLine, startCol, endLine, endCol int - - if draggingDown { - // Normal forward selection - startItemIdx = downItemIdx - endItemIdx = dragItemIdx - startLine = l.mouseDownY - startCol = l.mouseDownX - endLine = l.mouseDragY - endCol = l.mouseDragX - } else { - // Backward selection (dragging up) - startItemIdx = dragItemIdx - endItemIdx = downItemIdx - startLine = l.mouseDragY - startCol = l.mouseDragX - endLine = l.mouseDownY - endCol = l.mouseDownX - } - - slog.Info("Update highlight", "startItemIdx", startItemIdx, "endItemIdx", endItemIdx, - "startLine", startLine, "startCol", startCol, - "endLine", endLine, "endCol", endCol, - "draggingDown", draggingDown, - ) - - // Track newly highlighted items - // newHighlighted := make(map[int]bool) - - // Clear highlights on items that are no longer in range - // for i := range l.lastHighlighted { - // if i < startItemIdx || i > endItemIdx { - // if h, ok := l.items[i].(Highlightable); ok { - // h.SetHighlight(-1, -1, -1, -1) - // l.items[i] = h.(Item) - // l.invalidateItem(i) - // } - // } - // } - - // Highlight all items in range - // for idx := startItemIdx; idx <= endItemIdx; idx++ { - // item, ok := l.items[idx].(Highlightable) - // if !ok { - // continue - // } - // - // renderedItem := l.getItem(idx) - // - // if idx == startItemIdx && idx == endItemIdx { - // // Single item selection - // item.SetHighlight(startLine, startCol, endLine, endCol) - // } else if idx == startItemIdx { - // // First item - from start position to end of item - // item.SetHighlight(startLine, startCol, renderedItem.height-1, 9999) // 9999 = end of line - // } else if idx == endItemIdx { - // // Last item - from start of item to end position - // item.SetHighlight(0, 0, endLine, endCol) - // } else { - // // Middle item - fully highlighted - // item.SetHighlight(0, 0, renderedItem.height-1, 9999) - // } - // - // l.items[idx] = item.(Item) - // - // l.invalidateItem(idx) - // newHighlighted[idx] = true - // } - // - // l.lastHighlighted = newHighlighted -} - // countLines counts the number of lines in a string. func countLines(s string) int { if s == "" { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 6d58ce3b1b6ec5172c0ec3cfe0f1be239874ed60..910d41ea75aa8c67f37b356837f6ca9ba912980c 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -258,8 +258,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case uiChat: if msg.Y <= 0 { m.chat.ScrollBy(-1) + if !m.chat.SelectedItemInView() { + m.chat.SelectPrev() + m.chat.ScrollToSelected() + } } else if msg.Y >= m.chat.Height()-1 { m.chat.ScrollBy(1) + if !m.chat.SelectedItemInView() { + m.chat.SelectNext() + m.chat.ScrollToSelected() + } } x, y := msg.X, msg.Y From afb546733555e78a5970c544062e72e9c4f4360b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 10 Dec 2025 12:10:22 -0500 Subject: [PATCH 039/335] feat(ui): add mouse click handling to lazy list items --- internal/ui/lazylist/item.go | 12 +++++++++++- internal/ui/lazylist/list.go | 7 +++++++ internal/ui/model/items.go | 7 ++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/internal/ui/lazylist/item.go b/internal/ui/lazylist/item.go index 7c6904d7ad2cd30aec0fab62b9238141343c4e9f..2a1b68a9bd666d8bba104274d51e984edc30b76e 100644 --- a/internal/ui/lazylist/item.go +++ b/internal/ui/lazylist/item.go @@ -1,6 +1,9 @@ package lazylist -import "charm.land/lipgloss/v2" +import ( + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) // Item represents a single item in the lazy-loaded list. type Item interface { @@ -22,3 +25,10 @@ type HighlightStylable interface { // HighlightStyle returns the style to apply for highlighted regions. HighlightStyle() lipgloss.Style } + +// MouseClickable represents an item that can handle mouse click events. +type MouseClickable interface { + // HandleMouseClick processes a mouse click event at the given coordinates. + // It returns true if the event was handled, false otherwise. + HandleMouseClick(btn ansi.MouseButton, x, y int) bool +} diff --git a/internal/ui/lazylist/list.go b/internal/ui/lazylist/list.go index 423426ed256ea28a195aa7eacdbbeb6dc377d61c..319d69a777409c8c10528911aed30b34a83d623e 100644 --- a/internal/ui/lazylist/list.go +++ b/internal/ui/lazylist/list.go @@ -5,6 +5,7 @@ import ( "strings" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" ) // List represents a list of items that can be lazily rendered. A list is @@ -550,6 +551,12 @@ func (l *List) HandleMouseDown(x, y int) bool { // Select the clicked item l.SetSelected(itemIdx) + if clickable, ok := l.items[itemIdx].(MouseClickable); ok { + clickable.HandleMouseClick(ansi.MouseButton1, x, itemY) + l.items[itemIdx] = clickable.(Item) + l.invalidateItem(itemIdx) + } + return true } diff --git a/internal/ui/model/items.go b/internal/ui/model/items.go index 7e5f732ca8e60e824b3bced67feab3667e3823af..09fa1e74a60d2f94eaaf07859edd7383a6eef787 100644 --- a/internal/ui/model/items.go +++ b/internal/ui/model/items.go @@ -351,6 +351,7 @@ type SectionHeaderItem struct { duration time.Duration isSectionHeader bool sty *styles.Styles + content string } // NewSectionHeaderItem creates a new section header item. @@ -387,13 +388,13 @@ func (s *SectionHeaderItem) BlurStyle() lipgloss.Style { // Render implements lazylist.Item. func (s *SectionHeaderItem) Render(width int) string { - content := s.sty.Chat.Message.SectionHeader.Render(fmt.Sprintf("%s %s %s", + content := fmt.Sprintf("%s %s %s", s.sty.Subtle.Render(styles.ModelIcon), s.sty.Muted.Render(s.modelName), s.sty.Subtle.Render(s.duration.String()), - )) + ) - return content + return s.sty.Chat.Message.SectionHeader.Render(content) } // GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns From 6dc2dd8fdcfca657ceb572348c410dc32b1c7e91 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 10 Dec 2025 13:00:11 -0500 Subject: [PATCH 040/335] refactor(ui): cleanup and remove unused list code --- internal/ui/lazylist/highlight.go | 129 ---- internal/ui/list/example_test.go | 275 ------- internal/ui/list/item.go | 578 --------------- internal/ui/list/item_test.go | 593 --------------- internal/ui/list/lazylist.go | 1007 -------------------------- internal/ui/list/list.go | 1126 ----------------------------- internal/ui/list/list_test.go | 578 --------------- internal/ui/list/simplelist.go | 972 ------------------------- internal/ui/model/items.go | 354 +-------- 9 files changed, 4 insertions(+), 5608 deletions(-) delete mode 100644 internal/ui/list/example_test.go delete mode 100644 internal/ui/list/item.go delete mode 100644 internal/ui/list/item_test.go delete mode 100644 internal/ui/list/lazylist.go delete mode 100644 internal/ui/list/list.go delete mode 100644 internal/ui/list/list_test.go delete mode 100644 internal/ui/list/simplelist.go diff --git a/internal/ui/lazylist/highlight.go b/internal/ui/lazylist/highlight.go index c5b885238771d57f28e4177684c7e24e1336a5b8..e53d10353dd286fcfe2642db102736c27df34adc 100644 --- a/internal/ui/lazylist/highlight.go +++ b/internal/ui/lazylist/highlight.go @@ -86,135 +86,6 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin return buf.Render() } -// RenderWithHighlight renders content with optional focus styling and highlighting. -// This is a helper that combines common rendering logic for all items. -// The content parameter should be the raw rendered content before focus styling. -// The style parameter should come from CurrentStyle() and may be nil. -// func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string { -// // Apply focus/blur styling if configured -// rendered := content -// if style != nil { -// rendered = style.Render(rendered) -// } -// -// if !b.HasHighlight() { -// return rendered -// } -// -// height := lipgloss.Height(rendered) -// -// // Create temp buffer to draw content with highlighting -// tempBuf := uv.NewScreenBuffer(width, height) -// -// // Draw the rendered content to temp buffer -// styled := uv.NewStyledString(rendered) -// styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) -// -// // Apply highlighting if active -// b.ApplyHighlight(&tempBuf, width, height, style) -// -// return tempBuf.Render() -// } - -// ApplyHighlight applies highlighting to a screen buffer. -// This should be called after drawing content to the buffer. -// func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) { -// if b.highlightStartLine < 0 { -// return -// } -// -// var ( -// topMargin, topBorder, topPadding int -// rightMargin, rightBorder, rightPadding int -// bottomMargin, bottomBorder, bottomPadding int -// leftMargin, leftBorder, leftPadding int -// ) -// if style != nil { -// topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin() -// topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(), -// style.GetBorderRightSize(), -// style.GetBorderBottomSize(), -// style.GetBorderLeftSize() -// topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding() -// } -// -// slog.Info("Applying highlight", -// "highlightStartLine", b.highlightStartLine, -// "highlightStartCol", b.highlightStartCol, -// "highlightEndLine", b.highlightEndLine, -// "highlightEndCol", b.highlightEndCol, -// "width", width, -// "height", height, -// "margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin), -// "borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder), -// "paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding), -// ) -// -// // Calculate content area offsets -// contentArea := image.Rectangle{ -// Min: image.Point{ -// X: leftMargin + leftBorder + leftPadding, -// Y: topMargin + topBorder + topPadding, -// }, -// Max: image.Point{ -// X: width - (rightMargin + rightBorder + rightPadding), -// Y: height - (bottomMargin + bottomBorder + bottomPadding), -// }, -// } -// -// for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ { -// if y >= buf.Height() { -// break -// } -// -// line := buf.Line(y) -// -// // Determine column range for this line -// startCol := 0 -// if y == b.highlightStartLine { -// startCol = min(b.highlightStartCol, len(line)) -// } -// -// endCol := len(line) -// if y == b.highlightEndLine { -// endCol = min(b.highlightEndCol, len(line)) -// } -// -// // Track last non-empty position as we go -// lastContentX := -1 -// -// // Single pass: check content and track last non-empty position -// for x := startCol; x < endCol; x++ { -// cell := line.At(x) -// if cell == nil { -// continue -// } -// -// // Update last content position if non-empty -// if cell.Content != "" && cell.Content != " " { -// lastContentX = x -// } -// } -// -// // Only apply highlight up to last content position -// highlightEnd := endCol -// if lastContentX >= 0 { -// highlightEnd = lastContentX + 1 -// } else if lastContentX == -1 { -// highlightEnd = startCol // No content on this line -// } -// -// // Apply highlight style only to cells with content -// for x := startCol; x < highlightEnd; x++ { -// if !image.Pt(x, y).In(contentArea) { -// continue -// } -// cell := line.At(x) -// cell.Style = b.highlightStyle(cell.Style) -// } -// } -// } - // ToHighlighter converts a [lipgloss.Style] to a [Highlighter]. func ToHighlighter(lgStyle lipgloss.Style) Highlighter { return func(uv.Style) uv.Style { diff --git a/internal/ui/list/example_test.go b/internal/ui/list/example_test.go deleted file mode 100644 index e656fe059f16d98db84cccb8af328ffdc92864c1..0000000000000000000000000000000000000000 --- a/internal/ui/list/example_test.go +++ /dev/null @@ -1,275 +0,0 @@ -package list_test - -import ( - "fmt" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/ui/list" - uv "github.com/charmbracelet/ultraviolet" -) - -// Example demonstrates basic list usage with string items. -func Example_basic() { - // Create some items - items := []list.Item{ - list.NewStringItem("First item"), - list.NewStringItem("Second item"), - list.NewStringItem("Third item"), - } - - // Create a list with options - l := list.New(items...) - l.SetSize(80, 10) - l.SetSelected(0) - if true { - l.Focus() - } - - // Draw to a screen buffer - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - l.Draw(&screen, area) - - // Render to string - output := screen.Render() - fmt.Println(output) -} - -// BorderedItem demonstrates a focusable item with borders. -type BorderedItem struct { - id string - content string - focused bool - width int -} - -func NewBorderedItem(id, content string) *BorderedItem { - return &BorderedItem{ - id: id, - content: content, - width: 80, - } -} - -func (b *BorderedItem) ID() string { - return b.id -} - -func (b *BorderedItem) Height(width int) int { - // Account for border (2 lines for top and bottom) - b.width = width // Update width for rendering - return lipgloss.Height(b.render()) -} - -func (b *BorderedItem) Draw(scr uv.Screen, area uv.Rectangle) { - rendered := b.render() - styled := uv.NewStyledString(rendered) - styled.Draw(scr, area) -} - -func (b *BorderedItem) render() string { - style := lipgloss.NewStyle(). - Width(b.width-4). - Padding(0, 1) - - if b.focused { - style = style. - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("205")) - } else { - style = style. - Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")) - } - - return style.Render(b.content) -} - -func (b *BorderedItem) Focus() { - b.focused = true -} - -func (b *BorderedItem) Blur() { - b.focused = false -} - -func (b *BorderedItem) IsFocused() bool { - return b.focused -} - -// Example demonstrates focusable items with borders. -func Example_focusable() { - // Create focusable items - items := []list.Item{ - NewBorderedItem("1", "Focusable Item 1"), - NewBorderedItem("2", "Focusable Item 2"), - NewBorderedItem("3", "Focusable Item 3"), - } - - // Create list with first item selected and focused - l := list.New(items...) - l.SetSize(80, 20) - l.SetSelected(0) - if true { - l.Focus() - } - - // Draw to screen - screen := uv.NewScreenBuffer(80, 20) - area := uv.Rect(0, 0, 80, 20) - l.Draw(&screen, area) - - // The first item will have a colored border since it's focused - output := screen.Render() - fmt.Println(output) -} - -// Example demonstrates dynamic item updates. -func Example_dynamicUpdates() { - items := []list.Item{ - list.NewStringItem("Item 1"), - list.NewStringItem("Item 2"), - } - - l := list.New(items...) - l.SetSize(80, 10) - - // Draw initial state - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - l.Draw(&screen, area) - - // Update an item - l.UpdateItem(2, list.NewStringItem("Updated Item 2")) - - // Draw again - only changed item is re-rendered - l.Draw(&screen, area) - - // Append a new item - l.AppendItem(list.NewStringItem("New Item 3")) - - // Draw again - master buffer grows efficiently - l.Draw(&screen, area) - - output := screen.Render() - fmt.Println(output) -} - -// Example demonstrates scrolling with a large list. -func Example_scrolling() { - // Create many items - items := make([]list.Item, 100) - for i := range items { - items[i] = list.NewStringItem( - fmt.Sprintf("Item %d", i), - ) - } - - // Create list with small viewport - l := list.New(items...) - l.SetSize(80, 10) - l.SetSelected(0) - - // Draw initial view (shows items 0-9) - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - l.Draw(&screen, area) - - // Scroll down - l.ScrollBy(5) - l.Draw(&screen, area) // Now shows items 5-14 - - // Jump to specific item - l.ScrollToItem(50) - l.Draw(&screen, area) // Now shows item 50 and neighbors - - // Scroll to bottom - l.ScrollToBottom() - l.Draw(&screen, area) // Now shows last 10 items - - output := screen.Render() - fmt.Println(output) -} - -// VariableHeightItem demonstrates items with different heights. -type VariableHeightItem struct { - id string - lines []string - width int -} - -func NewVariableHeightItem(id string, lines []string) *VariableHeightItem { - return &VariableHeightItem{ - id: id, - lines: lines, - width: 80, - } -} - -func (v *VariableHeightItem) ID() string { - return v.id -} - -func (v *VariableHeightItem) Height(width int) int { - return len(v.lines) -} - -func (v *VariableHeightItem) Draw(scr uv.Screen, area uv.Rectangle) { - content := "" - for i, line := range v.lines { - if i > 0 { - content += "\n" - } - content += line - } - styled := uv.NewStyledString(content) - styled.Draw(scr, area) -} - -// Example demonstrates variable height items. -func Example_variableHeights() { - items := []list.Item{ - NewVariableHeightItem("1", []string{"Short item"}), - NewVariableHeightItem("2", []string{ - "This is a taller item", - "that spans multiple lines", - "to demonstrate variable heights", - }), - NewVariableHeightItem("3", []string{"Another short item"}), - NewVariableHeightItem("4", []string{ - "A medium height item", - "with two lines", - }), - } - - l := list.New(items...) - l.SetSize(80, 15) - - screen := uv.NewScreenBuffer(80, 15) - area := uv.Rect(0, 0, 80, 15) - l.Draw(&screen, area) - - output := screen.Render() - fmt.Println(output) -} - -// Example demonstrates markdown items in a list. -func Example_markdown() { - // Create markdown items - items := []list.Item{ - list.NewMarkdownItem("# Welcome\n\nThis is a **markdown** item."), - list.NewMarkdownItem("## Features\n\n- Supports **bold**\n- Supports *italic*\n- Supports `code`"), - list.NewMarkdownItem("### Code Block\n\n```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```"), - } - - // Create list - l := list.New(items...) - l.SetSize(80, 20) - - screen := uv.NewScreenBuffer(80, 20) - area := uv.Rect(0, 0, 80, 20) - l.Draw(&screen, area) - - output := screen.Render() - fmt.Println(output) -} diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go deleted file mode 100644 index 51f930d033d61d7f89634222f0f05b3e0041ac17..0000000000000000000000000000000000000000 --- a/internal/ui/list/item.go +++ /dev/null @@ -1,578 +0,0 @@ -package list - -import ( - "image" - "strings" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/glamour/v2" - "github.com/charmbracelet/glamour/v2/ansi" - uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/ultraviolet/screen" -) - -// toUVStyle converts a lipgloss.Style to a uv.Style, stripping multiline attributes. -func toUVStyle(lgStyle lipgloss.Style) uv.Style { - var uvStyle uv.Style - - // Colors are already color.Color - uvStyle.Fg = lgStyle.GetForeground() - uvStyle.Bg = lgStyle.GetBackground() - - // Build attributes using bitwise OR - var attrs uint8 - - if lgStyle.GetBold() { - attrs |= uv.AttrBold - } - - if lgStyle.GetItalic() { - attrs |= uv.AttrItalic - } - - if lgStyle.GetUnderline() { - uvStyle.Underline = uv.UnderlineSingle - } - - if lgStyle.GetStrikethrough() { - attrs |= uv.AttrStrikethrough - } - - if lgStyle.GetFaint() { - attrs |= uv.AttrFaint - } - - if lgStyle.GetBlink() { - attrs |= uv.AttrBlink - } - - if lgStyle.GetReverse() { - attrs |= uv.AttrReverse - } - - uvStyle.Attrs = attrs - - return uvStyle -} - -// Item represents a list item that can draw itself to a UV buffer. -// Items implement the uv.Drawable interface. -type Item interface { - uv.Drawable - - // Height returns the item's height in lines for the given width. - // This allows items to calculate height based on text wrapping and available space. - Height(width int) int -} - -// Focusable is an optional interface for items that support focus. -// When implemented, items can change appearance when focused (borders, colors, etc). -type Focusable interface { - Focus() - Blur() - IsFocused() bool -} - -// Highlightable is an optional interface for items that support highlighting. -// When implemented, items can highlight specific regions (e.g. for search matches). -type Highlightable interface { - // SetHighlight sets the highlight region (startLine, startCol) to (endLine, endCol). - // Use -1 for all values to clear highlighting. - SetHighlight(startLine, startCol, endLine, endCol int) - - // GetHighlight returns the current highlight region. - GetHighlight() (startLine, startCol, endLine, endCol int) -} - -// BaseFocusable provides common focus state and styling for items. -// Embed this type to add focus behavior to any item. -type BaseFocusable struct { - focused bool - focusStyle *lipgloss.Style - blurStyle *lipgloss.Style -} - -// Focus implements Focusable interface. -func (b *BaseFocusable) Focus() { - b.focused = true -} - -// Blur implements Focusable interface. -func (b *BaseFocusable) Blur() { - b.focused = false -} - -// IsFocused implements Focusable interface. -func (b *BaseFocusable) IsFocused() bool { - return b.focused -} - -// HasFocusStyles returns true if both focus and blur styles are configured. -func (b *BaseFocusable) HasFocusStyles() bool { - return b.focusStyle != nil && b.blurStyle != nil -} - -// CurrentStyle returns the current style based on focus state. -// Returns nil if no styles are configured, or if the current state's style is nil. -func (b *BaseFocusable) CurrentStyle() *lipgloss.Style { - if b.focused { - return b.focusStyle - } - return b.blurStyle -} - -// SetFocusStyles sets the focus and blur styles. -func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) { - b.focusStyle = focusStyle - b.blurStyle = blurStyle -} - -// BaseHighlightable provides common highlight state for items. -// Embed this type to add highlight behavior to any item. -type BaseHighlightable struct { - highlightStartLine int - highlightStartCol int - highlightEndLine int - highlightEndCol int - highlightStyle CellStyler -} - -// SetHighlight implements Highlightable interface. -func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) { - b.highlightStartLine = startLine - b.highlightStartCol = startCol - b.highlightEndLine = endLine - b.highlightEndCol = endCol -} - -// GetHighlight implements Highlightable interface. -func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) { - return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol -} - -// HasHighlight returns true if a highlight region is set. -func (b *BaseHighlightable) HasHighlight() bool { - return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 || - b.highlightEndLine >= 0 || b.highlightEndCol >= 0 -} - -// SetHighlightStyle sets the style function used for highlighting. -func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) { - b.highlightStyle = style -} - -// GetHighlightStyle returns the current highlight style function. -func (b *BaseHighlightable) GetHighlightStyle() CellStyler { - return b.highlightStyle -} - -// InitHighlight initializes the highlight fields with default values. -func (b *BaseHighlightable) InitHighlight() { - b.highlightStartLine = -1 - b.highlightStartCol = -1 - b.highlightEndLine = -1 - b.highlightEndCol = -1 - b.highlightStyle = LipglossStyleToCellStyler(lipgloss.NewStyle().Reverse(true)) -} - -// ApplyHighlight applies highlighting to a screen buffer. -// This should be called after drawing content to the buffer. -func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) { - if b.highlightStartLine < 0 { - return - } - - var ( - topMargin, topBorder, topPadding int - rightMargin, rightBorder, rightPadding int - bottomMargin, bottomBorder, bottomPadding int - leftMargin, leftBorder, leftPadding int - ) - if style != nil { - topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin() - topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(), - style.GetBorderRightSize(), - style.GetBorderBottomSize(), - style.GetBorderLeftSize() - topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding() - } - - // Calculate content area offsets - contentArea := image.Rectangle{ - Min: image.Point{ - X: leftMargin + leftBorder + leftPadding, - Y: topMargin + topBorder + topPadding, - }, - Max: image.Point{ - X: width - (rightMargin + rightBorder + rightPadding), - Y: height - (bottomMargin + bottomBorder + bottomPadding), - }, - } - - for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ { - if y >= buf.Height() { - break - } - - line := buf.Line(y) - - // Determine column range for this line - startCol := 0 - if y == b.highlightStartLine { - startCol = min(b.highlightStartCol, len(line)) - } - - endCol := len(line) - if y == b.highlightEndLine { - endCol = min(b.highlightEndCol, len(line)) - } - - // Track last non-empty position as we go - lastContentX := -1 - - // Single pass: check content and track last non-empty position - for x := startCol; x < endCol; x++ { - cell := line.At(x) - if cell == nil { - continue - } - - // Update last content position if non-empty - if cell.Content != "" && cell.Content != " " { - lastContentX = x - } - } - - // Only apply highlight up to last content position - highlightEnd := endCol - if lastContentX >= 0 { - highlightEnd = lastContentX + 1 - } else if lastContentX == -1 { - highlightEnd = startCol // No content on this line - } - - // Apply highlight style only to cells with content - for x := startCol; x < highlightEnd; x++ { - if !image.Pt(x, y).In(contentArea) { - continue - } - cell := line.At(x) - cell.Style = b.highlightStyle(cell.Style) - } - } -} - -// StringItem is a simple string-based item with optional text wrapping. -// It caches rendered content by width for efficient repeated rendering. -// StringItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles. -// StringItem implements Highlightable for text selection/search highlighting. -type StringItem struct { - BaseFocusable - BaseHighlightable - content string // Raw content string (may contain ANSI styles) - wrap bool // Whether to wrap text - - // Cache for rendered content at specific widths - // Key: width, Value: string - cache map[int]string -} - -// CellStyler is a function that applies styles to UV cells. -type CellStyler = func(s uv.Style) uv.Style - -var noColor = lipgloss.NoColor{} - -// LipglossStyleToCellStyler converts a Lip Gloss style to a CellStyler function. -func LipglossStyleToCellStyler(lgStyle lipgloss.Style) CellStyler { - uvStyle := toUVStyle(lgStyle) - return func(s uv.Style) uv.Style { - if uvStyle.Fg != nil && lgStyle.GetForeground() != noColor { - s.Fg = uvStyle.Fg - } - if uvStyle.Bg != nil && lgStyle.GetBackground() != noColor { - s.Bg = uvStyle.Bg - } - s.Attrs |= uvStyle.Attrs - if uvStyle.Underline != 0 { - s.Underline = uvStyle.Underline - } - return s - } -} - -// NewStringItem creates a new string item with the given ID and content. -func NewStringItem(content string) *StringItem { - s := &StringItem{ - content: content, - wrap: false, - cache: make(map[int]string), - } - s.InitHighlight() - return s -} - -// NewWrappingStringItem creates a new string item that wraps text to fit width. -func NewWrappingStringItem(content string) *StringItem { - s := &StringItem{ - content: content, - wrap: true, - cache: make(map[int]string), - } - s.InitHighlight() - return s -} - -// WithFocusStyles sets the focus and blur styles for the string item. -// If both styles are non-nil, the item will implement Focusable. -func (s *StringItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *StringItem { - s.SetFocusStyles(focusStyle, blurStyle) - return s -} - -// Height implements Item. -func (s *StringItem) Height(width int) int { - // Calculate content width if we have styles - contentWidth := width - if style := s.CurrentStyle(); style != nil { - hFrameSize := style.GetHorizontalFrameSize() - if hFrameSize > 0 { - contentWidth -= hFrameSize - } - } - - var lines int - if !s.wrap { - // No wrapping - height is just the number of newlines + 1 - lines = strings.Count(s.content, "\n") + 1 - } else { - // Use lipgloss.Wrap to wrap the content and count lines - // This preserves ANSI styles and is much faster than rendering to a buffer - wrapped := lipgloss.Wrap(s.content, contentWidth, "") - lines = strings.Count(wrapped, "\n") + 1 - } - - // Add vertical frame size if we have styles - if style := s.CurrentStyle(); style != nil { - lines += style.GetVerticalFrameSize() - } - - return lines -} - -// Draw implements Item and uv.Drawable. -func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) { - width := area.Dx() - height := area.Dy() - - // Check cache first - content, ok := s.cache[width] - if !ok { - // Not cached - create and cache - content = s.content - if s.wrap { - // Wrap content using lipgloss - content = lipgloss.Wrap(s.content, width, "") - } - s.cache[width] = content - } - - // Apply focus/blur styling if configured - style := s.CurrentStyle() - if style != nil { - content = style.Width(width).Render(content) - } - - // Create temp buffer to draw content with highlighting - tempBuf := uv.NewScreenBuffer(width, height) - - // Draw content to temp buffer first - styled := uv.NewStyledString(content) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - // Apply highlighting if active - s.ApplyHighlight(&tempBuf, width, height, style) - - // Copy temp buffer to actual screen at the target area - tempBuf.Draw(scr, area) -} - -// SetHighlight implements Highlightable and extends BaseHighlightable. -// Clears the cache when highlight changes. -func (s *StringItem) SetHighlight(startLine, startCol, endLine, endCol int) { - s.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) - // Clear cache when highlight changes - s.cache = make(map[int]string) -} - -// MarkdownItem renders markdown content using Glamour. -// It caches all rendered content by width for efficient repeated rendering. -// The wrap width is capped at 120 cells by default to ensure readable line lengths. -// MarkdownItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles. -// MarkdownItem implements Highlightable for text selection/search highlighting. -type MarkdownItem struct { - BaseFocusable - BaseHighlightable - markdown string // Raw markdown content - styleConfig *ansi.StyleConfig // Optional style configuration - maxWidth int // Maximum wrap width (default 120) - - // Cache for rendered content at specific widths - // Key: width (capped to maxWidth), Value: rendered markdown string - cache map[int]string -} - -// DefaultMarkdownMaxWidth is the default maximum width for markdown rendering. -const DefaultMarkdownMaxWidth = 120 - -// NewMarkdownItem creates a new markdown item with the given ID and markdown content. -// If focusStyle and blurStyle are both non-nil, the item will implement Focusable. -func NewMarkdownItem(markdown string) *MarkdownItem { - m := &MarkdownItem{ - markdown: markdown, - maxWidth: DefaultMarkdownMaxWidth, - cache: make(map[int]string), - } - m.InitHighlight() - return m -} - -// WithStyleConfig sets a custom Glamour style configuration for the markdown item. -func (m *MarkdownItem) WithStyleConfig(styleConfig ansi.StyleConfig) *MarkdownItem { - m.styleConfig = &styleConfig - return m -} - -// WithMaxWidth sets the maximum wrap width for markdown rendering. -func (m *MarkdownItem) WithMaxWidth(maxWidth int) *MarkdownItem { - m.maxWidth = maxWidth - return m -} - -// WithFocusStyles sets the focus and blur styles for the markdown item. -// If both styles are non-nil, the item will implement Focusable. -func (m *MarkdownItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *MarkdownItem { - m.SetFocusStyles(focusStyle, blurStyle) - return m -} - -// Height implements Item. -func (m *MarkdownItem) Height(width int) int { - // Render the markdown to get its height - rendered := m.renderMarkdown(width) - - // Apply focus/blur styling if configured to get accurate height - if style := m.CurrentStyle(); style != nil { - rendered = style.Render(rendered) - } - - return strings.Count(rendered, "\n") + 1 -} - -// Draw implements Item and uv.Drawable. -func (m *MarkdownItem) Draw(scr uv.Screen, area uv.Rectangle) { - width := area.Dx() - height := area.Dy() - rendered := m.renderMarkdown(width) - - // Apply focus/blur styling if configured - style := m.CurrentStyle() - if style != nil { - rendered = style.Render(rendered) - } - - // Create temp buffer to draw content with highlighting - tempBuf := uv.NewScreenBuffer(width, height) - - // Draw the rendered markdown to temp buffer - styled := uv.NewStyledString(rendered) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - // Apply highlighting if active - m.ApplyHighlight(&tempBuf, width, height, style) - - // Copy temp buffer to actual screen at the target area - tempBuf.Draw(scr, area) -} - -// renderMarkdown renders the markdown content at the given width, using cache if available. -// Width is always capped to maxWidth to ensure readable line lengths. -func (m *MarkdownItem) renderMarkdown(width int) string { - // Cap width to maxWidth - cappedWidth := min(width, m.maxWidth) - - // Check cache first (always cache all rendered markdown) - if cached, ok := m.cache[cappedWidth]; ok { - return cached - } - - // Not cached - render now - opts := []glamour.TermRendererOption{ - glamour.WithWordWrap(cappedWidth), - } - - // Add style config if provided - if m.styleConfig != nil { - opts = append(opts, glamour.WithStyles(*m.styleConfig)) - } - - renderer, err := glamour.NewTermRenderer(opts...) - if err != nil { - // Fallback to plain text on error - return m.markdown - } - - rendered, err := renderer.Render(m.markdown) - if err != nil { - // Fallback to plain text on error - return m.markdown - } - - // Trim trailing whitespace - rendered = strings.TrimRight(rendered, "\n\r ") - - // Always cache - m.cache[cappedWidth] = rendered - - return rendered -} - -// SetHighlight implements Highlightable and extends BaseHighlightable. -// Clears the cache when highlight changes. -func (m *MarkdownItem) SetHighlight(startLine, startCol, endLine, endCol int) { - m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) - // Clear cache when highlight changes - m.cache = make(map[int]string) -} - -// Gap is a 1-line spacer item used to add gaps between items. -var Gap = NewSpacerItem(1) - -// SpacerItem is an empty item that takes up vertical space. -// Useful for adding gaps between items in a list. -type SpacerItem struct { - height int -} - -var _ Item = (*SpacerItem)(nil) - -// NewSpacerItem creates a new spacer item with the given ID and height in lines. -func NewSpacerItem(height int) *SpacerItem { - return &SpacerItem{ - height: height, - } -} - -// Height implements Item. -func (s *SpacerItem) Height(width int) int { - return s.height -} - -// Draw implements Item. -// Spacer items don't draw anything, they just take up space. -func (s *SpacerItem) Draw(scr uv.Screen, area uv.Rectangle) { - // Ensure the area is filled with spaces to clear any existing content - spacerArea := uv.Rect(area.Min.X, area.Min.Y, area.Dx(), area.Min.Y+min(1, s.height)) - if spacerArea.Overlaps(area) { - screen.ClearArea(scr, spacerArea) - } -} diff --git a/internal/ui/list/item_test.go b/internal/ui/list/item_test.go deleted file mode 100644 index 550a7b3a3cbe1bda035e91fca3efd01df87395db..0000000000000000000000000000000000000000 --- a/internal/ui/list/item_test.go +++ /dev/null @@ -1,593 +0,0 @@ -package list - -import ( - "strings" - "testing" - - "github.com/charmbracelet/glamour/v2/ansi" - uv "github.com/charmbracelet/ultraviolet" -) - -func TestRenderHelper(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - } - - l := New(items...) - l.SetSize(80, 10) - - // Render to string - output := l.Render() - - if len(output) == 0 { - t.Error("expected non-empty output from Render()") - } - - // Check that output contains the items - if !strings.Contains(output, "Item 1") { - t.Error("expected output to contain 'Item 1'") - } - if !strings.Contains(output, "Item 2") { - t.Error("expected output to contain 'Item 2'") - } - if !strings.Contains(output, "Item 3") { - t.Error("expected output to contain 'Item 3'") - } -} - -func TestRenderWithScrolling(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - NewStringItem("Item 4"), - NewStringItem("Item 5"), - } - - l := New(items...) - l.SetSize(80, 2) // Small viewport - - // Initial render should show first 2 items - output := l.Render() - if !strings.Contains(output, "Item 1") { - t.Error("expected output to contain 'Item 1'") - } - if !strings.Contains(output, "Item 2") { - t.Error("expected output to contain 'Item 2'") - } - if strings.Contains(output, "Item 3") { - t.Error("expected output to NOT contain 'Item 3' in initial view") - } - - // Scroll down and render - l.ScrollBy(2) - output = l.Render() - - // Now should show items 3 and 4 - if strings.Contains(output, "Item 1") { - t.Error("expected output to NOT contain 'Item 1' after scrolling") - } - if !strings.Contains(output, "Item 3") { - t.Error("expected output to contain 'Item 3' after scrolling") - } - if !strings.Contains(output, "Item 4") { - t.Error("expected output to contain 'Item 4' after scrolling") - } -} - -func TestRenderEmptyList(t *testing.T) { - l := New() - l.SetSize(80, 10) - - output := l.Render() - if output != "" { - t.Errorf("expected empty output for empty list, got: %q", output) - } -} - -func TestRenderVsDrawConsistency(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - } - - l := New(items...) - l.SetSize(80, 10) - - // Render using Render() method - renderOutput := l.Render() - - // Render using Draw() method - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - l.Draw(&screen, area) - drawOutput := screen.Render() - - // Trim any trailing whitespace for comparison - renderOutput = strings.TrimRight(renderOutput, "\n") - drawOutput = strings.TrimRight(drawOutput, "\n") - - // Both methods should produce the same output - if renderOutput != drawOutput { - t.Errorf("Render() and Draw() produced different outputs:\nRender():\n%q\n\nDraw():\n%q", - renderOutput, drawOutput) - } -} - -func BenchmarkRender(b *testing.B) { - items := make([]Item, 100) - for i := range items { - items[i] = NewStringItem("Item content here") - } - - l := New(items...) - l.SetSize(80, 24) - l.Render() // Prime the buffer - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = l.Render() - } -} - -func BenchmarkRenderWithScrolling(b *testing.B) { - items := make([]Item, 1000) - for i := range items { - items[i] = NewStringItem("Item content here") - } - - l := New(items...) - l.SetSize(80, 24) - l.Render() // Prime the buffer - - b.ResetTimer() - for i := 0; i < b.N; i++ { - l.ScrollBy(1) - _ = l.Render() - } -} - -func TestStringItemCache(t *testing.T) { - item := NewStringItem("Test content") - - // First draw at width 80 should populate cache - screen1 := uv.NewScreenBuffer(80, 5) - area1 := uv.Rect(0, 0, 80, 5) - item.Draw(&screen1, area1) - - if len(item.cache) != 1 { - t.Errorf("expected cache to have 1 entry after first draw, got %d", len(item.cache)) - } - if _, ok := item.cache[80]; !ok { - t.Error("expected cache to have entry for width 80") - } - - // Second draw at same width should reuse cache - screen2 := uv.NewScreenBuffer(80, 5) - area2 := uv.Rect(0, 0, 80, 5) - item.Draw(&screen2, area2) - - if len(item.cache) != 1 { - t.Errorf("expected cache to still have 1 entry after second draw, got %d", len(item.cache)) - } - - // Draw at different width should add to cache - screen3 := uv.NewScreenBuffer(40, 5) - area3 := uv.Rect(0, 0, 40, 5) - item.Draw(&screen3, area3) - - if len(item.cache) != 2 { - t.Errorf("expected cache to have 2 entries after draw at different width, got %d", len(item.cache)) - } - if _, ok := item.cache[40]; !ok { - t.Error("expected cache to have entry for width 40") - } -} - -func TestWrappingItemHeight(t *testing.T) { - // Short text that fits in one line - item1 := NewWrappingStringItem("Short") - if h := item1.Height(80); h != 1 { - t.Errorf("expected height 1 for short text, got %d", h) - } - - // Long text that wraps - longText := "This is a very long line that will definitely wrap when constrained to a narrow width" - item2 := NewWrappingStringItem(longText) - - // At width 80, should be fewer lines than width 20 - height80 := item2.Height(80) - height20 := item2.Height(20) - - if height20 <= height80 { - t.Errorf("expected more lines at narrow width (20: %d lines) than wide width (80: %d lines)", - height20, height80) - } - - // Non-wrapping version should always be 1 line - item3 := NewStringItem(longText) - if h := item3.Height(20); h != 1 { - t.Errorf("expected height 1 for non-wrapping item, got %d", h) - } -} - -func TestMarkdownItemBasic(t *testing.T) { - markdown := "# Hello\n\nThis is a **test**." - item := NewMarkdownItem(markdown) - - // Test that height is calculated - height := item.Height(80) - if height < 1 { - t.Errorf("expected height >= 1, got %d", height) - } - - // Test drawing - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - item.Draw(&screen, area) - - // Should not panic and should render something - rendered := screen.Render() - if len(rendered) == 0 { - t.Error("expected non-empty rendered output") - } -} - -func TestMarkdownItemCache(t *testing.T) { - markdown := "# Test\n\nSome content." - item := NewMarkdownItem(markdown) - - // First render at width 80 should populate cache - height1 := item.Height(80) - if len(item.cache) != 1 { - t.Errorf("expected cache to have 1 entry after first render, got %d", len(item.cache)) - } - - // Second render at same width should reuse cache - height2 := item.Height(80) - if height1 != height2 { - t.Errorf("expected consistent height, got %d then %d", height1, height2) - } - if len(item.cache) != 1 { - t.Errorf("expected cache to still have 1 entry, got %d", len(item.cache)) - } - - // Render at different width should add to cache - _ = item.Height(40) - if len(item.cache) != 2 { - t.Errorf("expected cache to have 2 entries after different width, got %d", len(item.cache)) - } -} - -func TestMarkdownItemMaxCacheWidth(t *testing.T) { - markdown := "# Test\n\nSome content." - item := NewMarkdownItem(markdown).WithMaxWidth(50) - - // Render at width 40 (below limit) - should cache at width 40 - _ = item.Height(40) - if len(item.cache) != 1 { - t.Errorf("expected cache to have 1 entry for width 40, got %d", len(item.cache)) - } - - // Render at width 80 (above limit) - should cap to 50 and cache - _ = item.Height(80) - // Cache should have width 50 entry (capped from 80) - if len(item.cache) != 2 { - t.Errorf("expected cache to have 2 entries (40 and 50), got %d", len(item.cache)) - } - if _, ok := item.cache[50]; !ok { - t.Error("expected cache to have entry for width 50 (capped from 80)") - } - - // Render at width 100 (also above limit) - should reuse cached width 50 - _ = item.Height(100) - if len(item.cache) != 2 { - t.Errorf("expected cache to still have 2 entries (reusing 50), got %d", len(item.cache)) - } -} - -func TestMarkdownItemWithStyleConfig(t *testing.T) { - markdown := "# Styled\n\nContent with **bold** text." - - // Create a custom style config - styleConfig := ansi.StyleConfig{ - Document: ansi.StyleBlock{ - Margin: uintPtr(0), - }, - } - - item := NewMarkdownItem(markdown).WithStyleConfig(styleConfig) - - // Render should use the custom style - height := item.Height(80) - if height < 1 { - t.Errorf("expected height >= 1, got %d", height) - } - - // Draw should work without panic - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - item.Draw(&screen, area) - - rendered := screen.Render() - if len(rendered) == 0 { - t.Error("expected non-empty rendered output with custom style") - } -} - -func TestMarkdownItemInList(t *testing.T) { - items := []Item{ - NewMarkdownItem("# First\n\nMarkdown item."), - NewMarkdownItem("# Second\n\nAnother item."), - NewStringItem("Regular string item"), - } - - l := New(items...) - l.SetSize(80, 20) - - // Should render without error - output := l.Render() - if len(output) == 0 { - t.Error("expected non-empty output from list with markdown items") - } - - // Should contain content from markdown items - if !strings.Contains(output, "First") { - t.Error("expected output to contain 'First'") - } - if !strings.Contains(output, "Second") { - t.Error("expected output to contain 'Second'") - } - if !strings.Contains(output, "Regular string item") { - t.Error("expected output to contain 'Regular string item'") - } -} - -func TestMarkdownItemHeightWithWidth(t *testing.T) { - // Test that widths are capped to maxWidth - markdown := "This is a paragraph with some text." - - item := NewMarkdownItem(markdown).WithMaxWidth(50) - - // At width 30 (below limit), should cache and render at width 30 - height30 := item.Height(30) - if height30 < 1 { - t.Errorf("expected height >= 1, got %d", height30) - } - - // At width 100 (above maxWidth), should cap to 50 and cache - height100 := item.Height(100) - if height100 < 1 { - t.Errorf("expected height >= 1, got %d", height100) - } - - // Both should be cached (width 30 and capped width 50) - if len(item.cache) != 2 { - t.Errorf("expected cache to have 2 entries (30 and 50), got %d", len(item.cache)) - } - if _, ok := item.cache[30]; !ok { - t.Error("expected cache to have entry for width 30") - } - if _, ok := item.cache[50]; !ok { - t.Error("expected cache to have entry for width 50 (capped from 100)") - } -} - -func BenchmarkMarkdownItemRender(b *testing.B) { - markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3" - item := NewMarkdownItem(markdown) - - // Prime the cache - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - item.Draw(&screen, area) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - item.Draw(&screen, area) - } -} - -func BenchmarkMarkdownItemUncached(b *testing.B) { - markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - item := NewMarkdownItem(markdown) - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - item.Draw(&screen, area) - } -} - -func TestSpacerItem(t *testing.T) { - spacer := NewSpacerItem(3) - - // Check height - if h := spacer.Height(80); h != 3 { - t.Errorf("expected height 3, got %d", h) - } - - // Height should be constant regardless of width - if h := spacer.Height(20); h != 3 { - t.Errorf("expected height 3 for width 20, got %d", h) - } - - // Draw should not produce any visible content - screen := uv.NewScreenBuffer(20, 3) - area := uv.Rect(0, 0, 20, 3) - spacer.Draw(&screen, area) - - output := screen.Render() - // Should be empty (just spaces) - for _, line := range strings.Split(output, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - t.Errorf("expected empty spacer output, got: %q", line) - } - } -} - -func TestSpacerItemInList(t *testing.T) { - // Create a list with items separated by spacers - items := []Item{ - NewStringItem("Item 1"), - NewSpacerItem(1), - NewStringItem("Item 2"), - NewSpacerItem(2), - NewStringItem("Item 3"), - } - - l := New(items...) - l.SetSize(20, 10) - - output := l.Render() - - // Should contain all three items - if !strings.Contains(output, "Item 1") { - t.Error("expected output to contain 'Item 1'") - } - if !strings.Contains(output, "Item 2") { - t.Error("expected output to contain 'Item 2'") - } - if !strings.Contains(output, "Item 3") { - t.Error("expected output to contain 'Item 3'") - } - - // Total height should be: 1 (item1) + 1 (spacer1) + 1 (item2) + 2 (spacer2) + 1 (item3) = 6 - expectedHeight := 6 - if l.TotalHeight() != expectedHeight { - t.Errorf("expected total height %d, got %d", expectedHeight, l.TotalHeight()) - } -} - -func TestSpacerItemNavigation(t *testing.T) { - // Spacers should not be selectable (they're not focusable) - items := []Item{ - NewStringItem("Item 1"), - NewSpacerItem(1), - NewStringItem("Item 2"), - } - - l := New(items...) - l.SetSize(20, 10) - - // Select first item - l.SetSelected(0) - if l.SelectedIndex() != 0 { - t.Errorf("expected selected index 0, got %d", l.SelectedIndex()) - } - - // Can select the spacer (it's a valid item, just not focusable) - l.SetSelected(1) - if l.SelectedIndex() != 1 { - t.Errorf("expected selected index 1, got %d", l.SelectedIndex()) - } - - // Can select item after spacer - l.SetSelected(2) - if l.SelectedIndex() != 2 { - t.Errorf("expected selected index 2, got %d", l.SelectedIndex()) - } -} - -// Helper function to create a pointer to uint -func uintPtr(v uint) *uint { - return &v -} - -func TestListDoesNotEatLastLine(t *testing.T) { - // Create items that exactly fill the viewport - items := []Item{ - NewStringItem("Line 1"), - NewStringItem("Line 2"), - NewStringItem("Line 3"), - NewStringItem("Line 4"), - NewStringItem("Line 5"), - } - - // Create list with height exactly matching content (5 lines, no gaps) - l := New(items...) - l.SetSize(20, 5) - - // Render the list - output := l.Render() - - // Count actual lines in output - lines := strings.Split(strings.TrimRight(output, "\n"), "\n") - actualLineCount := 0 - for _, line := range lines { - if strings.TrimSpace(line) != "" { - actualLineCount++ - } - } - - // All 5 items should be visible - if !strings.Contains(output, "Line 1") { - t.Error("expected output to contain 'Line 1'") - } - if !strings.Contains(output, "Line 2") { - t.Error("expected output to contain 'Line 2'") - } - if !strings.Contains(output, "Line 3") { - t.Error("expected output to contain 'Line 3'") - } - if !strings.Contains(output, "Line 4") { - t.Error("expected output to contain 'Line 4'") - } - if !strings.Contains(output, "Line 5") { - t.Error("expected output to contain 'Line 5'") - } - - if actualLineCount != 5 { - t.Errorf("expected 5 lines with content, got %d", actualLineCount) - } -} - -func TestListWithScrollDoesNotEatLastLine(t *testing.T) { - // Create more items than viewport height - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - NewStringItem("Item 4"), - NewStringItem("Item 5"), - NewStringItem("Item 6"), - NewStringItem("Item 7"), - } - - // Viewport shows 3 items at a time - l := New(items...) - l.SetSize(20, 3) - - // Need to render first to build the buffer and calculate total height - _ = l.Render() - - // Now scroll to bottom - l.ScrollToBottom() - - output := l.Render() - - t.Logf("Output:\n%s", output) - t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight()) - - // Should show last 3 items: 5, 6, 7 - if !strings.Contains(output, "Item 5") { - t.Error("expected output to contain 'Item 5'") - } - if !strings.Contains(output, "Item 6") { - t.Error("expected output to contain 'Item 6'") - } - if !strings.Contains(output, "Item 7") { - t.Error("expected output to contain 'Item 7'") - } - - // Should not show earlier items - if strings.Contains(output, "Item 1") { - t.Error("expected output to NOT contain 'Item 1' when scrolled to bottom") - } -} diff --git a/internal/ui/list/lazylist.go b/internal/ui/list/lazylist.go deleted file mode 100644 index 58ce3bf3eff9869f220af3574fc920865090e172..0000000000000000000000000000000000000000 --- a/internal/ui/list/lazylist.go +++ /dev/null @@ -1,1007 +0,0 @@ -package list - -import ( - "strings" - - uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/ultraviolet/screen" -) - -// LazyList is a virtual scrolling list that only renders visible items. -// It uses height estimates to avoid expensive renders during initial layout. -type LazyList struct { - // Configuration - width, height int - - // Data - items []Item - - // Focus & Selection - focused bool - selectedIdx int // Currently selected item index (-1 if none) - - // Item positioning - tracks measured and estimated positions - itemHeights []itemHeight - totalHeight int // Sum of all item heights (measured or estimated) - - // Viewport state - offset int // Scroll offset in lines from top - - // Rendered items cache - only visible items are rendered - renderedCache map[int]*renderedItemCache - - // Virtual scrolling configuration - defaultEstimate int // Default height estimate for unmeasured items - overscan int // Number of items to render outside viewport for smooth scrolling - - // Dirty tracking - needsLayout bool - dirtyItems map[int]bool - dirtyViewport bool // True if we need to re-render viewport - - // Mouse state - mouseDown bool - mouseDownItem int - mouseDownX int - mouseDownY int - mouseDragItem int - mouseDragX int - mouseDragY int -} - -// itemHeight tracks the height of an item - either measured or estimated. -type itemHeight struct { - height int - measured bool // true if height is actual measurement, false if estimate -} - -// renderedItemCache stores a rendered item's buffer. -type renderedItemCache struct { - buffer *uv.ScreenBuffer - height int // Actual measured height after rendering -} - -// NewLazyList creates a new lazy-rendering list. -func NewLazyList(items ...Item) *LazyList { - l := &LazyList{ - items: items, - itemHeights: make([]itemHeight, len(items)), - renderedCache: make(map[int]*renderedItemCache), - dirtyItems: make(map[int]bool), - selectedIdx: -1, - mouseDownItem: -1, - mouseDragItem: -1, - defaultEstimate: 10, // Conservative estimate: 5 lines per item - overscan: 5, // Render 3 items above/below viewport - needsLayout: true, - dirtyViewport: true, - } - - // Initialize all items with estimated heights - for i := range l.items { - l.itemHeights[i] = itemHeight{ - height: l.defaultEstimate, - measured: false, - } - } - l.calculateTotalHeight() - - return l -} - -// calculateTotalHeight sums all item heights (measured or estimated). -func (l *LazyList) calculateTotalHeight() { - l.totalHeight = 0 - for _, h := range l.itemHeights { - l.totalHeight += h.height - } -} - -// getItemPosition returns the Y position where an item starts. -func (l *LazyList) getItemPosition(idx int) int { - pos := 0 - for i := 0; i < idx && i < len(l.itemHeights); i++ { - pos += l.itemHeights[i].height - } - return pos -} - -// findVisibleItems returns the range of items that are visible or near the viewport. -func (l *LazyList) findVisibleItems() (firstIdx, lastIdx int) { - if len(l.items) == 0 { - return 0, 0 - } - - viewportStart := l.offset - viewportEnd := l.offset + l.height - - // Find first visible item - firstIdx = -1 - pos := 0 - for i := 0; i < len(l.items); i++ { - itemEnd := pos + l.itemHeights[i].height - if itemEnd > viewportStart { - firstIdx = i - break - } - pos = itemEnd - } - - // Apply overscan above - firstIdx = max(0, firstIdx-l.overscan) - - // Find last visible item - lastIdx = firstIdx - pos = l.getItemPosition(firstIdx) - for i := firstIdx; i < len(l.items); i++ { - if pos >= viewportEnd { - break - } - pos += l.itemHeights[i].height - lastIdx = i - } - - // Apply overscan below - lastIdx = min(len(l.items)-1, lastIdx+l.overscan) - - return firstIdx, lastIdx -} - -// renderItem renders a single item and caches it. -// Returns the actual measured height. -func (l *LazyList) renderItem(idx int) int { - if idx < 0 || idx >= len(l.items) { - return 0 - } - - item := l.items[idx] - - // Measure actual height - actualHeight := item.Height(l.width) - - // Create buffer and render - buf := uv.NewScreenBuffer(l.width, actualHeight) - area := uv.Rect(0, 0, l.width, actualHeight) - item.Draw(&buf, area) - - // Cache rendered item - l.renderedCache[idx] = &renderedItemCache{ - buffer: &buf, - height: actualHeight, - } - - // Update height if it was estimated or changed - if !l.itemHeights[idx].measured || l.itemHeights[idx].height != actualHeight { - oldHeight := l.itemHeights[idx].height - l.itemHeights[idx] = itemHeight{ - height: actualHeight, - measured: true, - } - - // Adjust total height - l.totalHeight += actualHeight - oldHeight - } - - return actualHeight -} - -// Draw implements uv.Drawable. -func (l *LazyList) Draw(scr uv.Screen, area uv.Rectangle) { - if area.Dx() <= 0 || area.Dy() <= 0 { - return - } - - widthChanged := l.width != area.Dx() - heightChanged := l.height != area.Dy() - - l.width = area.Dx() - l.height = area.Dy() - - // Width changes invalidate all cached renders - if widthChanged { - l.renderedCache = make(map[int]*renderedItemCache) - // Mark all heights as needing remeasurement - for i := range l.itemHeights { - l.itemHeights[i].measured = false - l.itemHeights[i].height = l.defaultEstimate - } - l.calculateTotalHeight() - l.needsLayout = true - l.dirtyViewport = true - } - - if heightChanged { - l.clampOffset() - l.dirtyViewport = true - } - - if len(l.items) == 0 { - screen.ClearArea(scr, area) - return - } - - // Find visible items based on current estimates - firstIdx, lastIdx := l.findVisibleItems() - - // Track the first visible item's position to maintain stability - // Only stabilize if we're not at the top boundary - stabilizeIdx := -1 - stabilizeY := 0 - if l.offset > 0 { - for i := firstIdx; i <= lastIdx; i++ { - itemPos := l.getItemPosition(i) - if itemPos >= l.offset { - stabilizeIdx = i - stabilizeY = itemPos - break - } - } - } - - // Track if any heights changed during rendering - heightsChanged := false - - // Render visible items that aren't cached (measurement pass) - for i := firstIdx; i <= lastIdx; i++ { - if _, cached := l.renderedCache[i]; !cached { - oldHeight := l.itemHeights[i].height - l.renderItem(i) - if l.itemHeights[i].height != oldHeight { - heightsChanged = true - } - } else if l.dirtyItems[i] { - // Re-render dirty items - oldHeight := l.itemHeights[i].height - l.renderItem(i) - delete(l.dirtyItems, i) - if l.itemHeights[i].height != oldHeight { - heightsChanged = true - } - } - } - - // If heights changed, adjust offset to keep stabilization point stable - if heightsChanged && stabilizeIdx >= 0 { - newStabilizeY := l.getItemPosition(stabilizeIdx) - offsetDelta := newStabilizeY - stabilizeY - - // Adjust offset to maintain visual stability - l.offset += offsetDelta - l.clampOffset() - - // Re-find visible items with adjusted positions - firstIdx, lastIdx = l.findVisibleItems() - - // Render any newly visible items after position adjustments - for i := firstIdx; i <= lastIdx; i++ { - if _, cached := l.renderedCache[i]; !cached { - l.renderItem(i) - } - } - } - - // Clear old cache entries outside visible range - if len(l.renderedCache) > (lastIdx-firstIdx+1)*2 { - l.pruneCache(firstIdx, lastIdx) - } - - // Composite visible items into viewport with stable positions - l.drawViewport(scr, area, firstIdx, lastIdx) - - l.dirtyViewport = false - l.needsLayout = false -} - -// drawViewport composites visible items into the screen. -func (l *LazyList) drawViewport(scr uv.Screen, area uv.Rectangle, firstIdx, lastIdx int) { - screen.ClearArea(scr, area) - - itemStartY := l.getItemPosition(firstIdx) - - for i := firstIdx; i <= lastIdx; i++ { - cached, ok := l.renderedCache[i] - if !ok { - continue - } - - // Calculate where this item appears in viewport - itemY := itemStartY - l.offset - itemHeight := cached.height - - // Skip if entirely above viewport - if itemY+itemHeight < 0 { - itemStartY += itemHeight - continue - } - - // Stop if entirely below viewport - if itemY >= l.height { - break - } - - // Calculate visible portion of item - srcStartY := 0 - dstStartY := itemY - - if itemY < 0 { - // Item starts above viewport - srcStartY = -itemY - dstStartY = 0 - } - - srcEndY := srcStartY + (l.height - dstStartY) - if srcEndY > itemHeight { - srcEndY = itemHeight - } - - // Copy visible lines from item buffer to screen - buf := cached.buffer.Buffer - destY := area.Min.Y + dstStartY - - for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ { - if srcY >= buf.Height() { - break - } - - line := buf.Line(srcY) - destX := area.Min.X - - for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ { - cell := line.At(x) - scr.SetCell(destX, destY, cell) - destX++ - } - destY++ - } - - itemStartY += itemHeight - } -} - -// pruneCache removes cached items outside the visible range. -func (l *LazyList) pruneCache(firstIdx, lastIdx int) { - keepStart := max(0, firstIdx-l.overscan*2) - keepEnd := min(len(l.items)-1, lastIdx+l.overscan*2) - - for idx := range l.renderedCache { - if idx < keepStart || idx > keepEnd { - delete(l.renderedCache, idx) - } - } -} - -// clampOffset ensures scroll offset stays within valid bounds. -func (l *LazyList) clampOffset() { - maxOffset := l.totalHeight - l.height - if maxOffset < 0 { - maxOffset = 0 - } - - if l.offset > maxOffset { - l.offset = maxOffset - } - if l.offset < 0 { - l.offset = 0 - } -} - -// SetItems replaces all items in the list. -func (l *LazyList) SetItems(items []Item) { - l.items = items - l.itemHeights = make([]itemHeight, len(items)) - l.renderedCache = make(map[int]*renderedItemCache) - l.dirtyItems = make(map[int]bool) - - // Initialize with estimates - for i := range l.items { - l.itemHeights[i] = itemHeight{ - height: l.defaultEstimate, - measured: false, - } - } - l.calculateTotalHeight() - l.needsLayout = true - l.dirtyViewport = true -} - -// AppendItem adds an item to the end of the list. -func (l *LazyList) AppendItem(item Item) { - l.items = append(l.items, item) - l.itemHeights = append(l.itemHeights, itemHeight{ - height: l.defaultEstimate, - measured: false, - }) - l.totalHeight += l.defaultEstimate - l.dirtyViewport = true -} - -// PrependItem adds an item to the beginning of the list. -func (l *LazyList) PrependItem(item Item) { - l.items = append([]Item{item}, l.items...) - l.itemHeights = append([]itemHeight{{ - height: l.defaultEstimate, - measured: false, - }}, l.itemHeights...) - - // Shift cache indices - newCache := make(map[int]*renderedItemCache) - for idx, cached := range l.renderedCache { - newCache[idx+1] = cached - } - l.renderedCache = newCache - - l.totalHeight += l.defaultEstimate - l.offset += l.defaultEstimate // Maintain scroll position - l.dirtyViewport = true -} - -// UpdateItem replaces an item at the given index. -func (l *LazyList) UpdateItem(idx int, item Item) { - if idx < 0 || idx >= len(l.items) { - return - } - - l.items[idx] = item - delete(l.renderedCache, idx) - l.dirtyItems[idx] = true - // Keep height estimate - will remeasure on next render - l.dirtyViewport = true -} - -// ScrollBy scrolls by the given number of lines. -func (l *LazyList) ScrollBy(delta int) { - l.offset += delta - l.clampOffset() - l.dirtyViewport = true -} - -// ScrollToBottom scrolls to the end of the list. -func (l *LazyList) ScrollToBottom() { - l.offset = l.totalHeight - l.height - l.clampOffset() - l.dirtyViewport = true -} - -// ScrollToTop scrolls to the beginning of the list. -func (l *LazyList) ScrollToTop() { - l.offset = 0 - l.dirtyViewport = true -} - -// Len returns the number of items in the list. -func (l *LazyList) Len() int { - return len(l.items) -} - -// Focus sets the list as focused. -func (l *LazyList) Focus() { - l.focused = true - l.focusSelectedItem() - l.dirtyViewport = true -} - -// Blur removes focus from the list. -func (l *LazyList) Blur() { - l.focused = false - l.blurSelectedItem() - l.dirtyViewport = true -} - -// focusSelectedItem focuses the currently selected item if it's focusable. -func (l *LazyList) focusSelectedItem() { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return - } - - item := l.items[l.selectedIdx] - if f, ok := item.(Focusable); ok { - f.Focus() - delete(l.renderedCache, l.selectedIdx) - l.dirtyItems[l.selectedIdx] = true - } -} - -// blurSelectedItem blurs the currently selected item if it's focusable. -func (l *LazyList) blurSelectedItem() { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return - } - - item := l.items[l.selectedIdx] - if f, ok := item.(Focusable); ok { - f.Blur() - delete(l.renderedCache, l.selectedIdx) - l.dirtyItems[l.selectedIdx] = true - } -} - -// IsFocused returns whether the list is focused. -func (l *LazyList) IsFocused() bool { - return l.focused -} - -// Width returns the current viewport width. -func (l *LazyList) Width() int { - return l.width -} - -// Height returns the current viewport height. -func (l *LazyList) Height() int { - return l.height -} - -// SetSize sets the viewport size explicitly. -// This is useful when you want to pre-configure the list size before drawing. -func (l *LazyList) SetSize(width, height int) { - widthChanged := l.width != width - heightChanged := l.height != height - - l.width = width - l.height = height - - // Width changes invalidate all cached renders - if widthChanged && width > 0 { - l.renderedCache = make(map[int]*renderedItemCache) - // Mark all heights as needing remeasurement - for i := range l.itemHeights { - l.itemHeights[i].measured = false - l.itemHeights[i].height = l.defaultEstimate - } - l.calculateTotalHeight() - l.needsLayout = true - l.dirtyViewport = true - } - - if heightChanged && height > 0 { - l.clampOffset() - l.dirtyViewport = true - } - - // After cache invalidation, scroll to selected item or bottom - if widthChanged || heightChanged { - if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) { - // Scroll to selected item - l.ScrollToSelected() - } else if len(l.items) > 0 { - // No selection - scroll to bottom - l.ScrollToBottom() - } - } -} - -// Selection methods - -// Selected returns the currently selected item index (-1 if none). -func (l *LazyList) Selected() int { - return l.selectedIdx -} - -// SetSelected sets the selected item by index. -func (l *LazyList) SetSelected(idx int) { - if idx < -1 || idx >= len(l.items) { - return - } - - if l.selectedIdx != idx { - prevIdx := l.selectedIdx - l.selectedIdx = idx - l.dirtyViewport = true - - // Update focus states if list is focused. - if l.focused { - // Blur previously selected item. - if prevIdx >= 0 && prevIdx < len(l.items) { - if f, ok := l.items[prevIdx].(Focusable); ok { - f.Blur() - delete(l.renderedCache, prevIdx) - l.dirtyItems[prevIdx] = true - } - } - - // Focus newly selected item. - if idx >= 0 && idx < len(l.items) { - if f, ok := l.items[idx].(Focusable); ok { - f.Focus() - delete(l.renderedCache, idx) - l.dirtyItems[idx] = true - } - } - } - } -} - -// SelectPrev selects the previous item. -func (l *LazyList) SelectPrev() { - if len(l.items) == 0 { - return - } - - if l.selectedIdx <= 0 { - l.selectedIdx = 0 - } else { - l.selectedIdx-- - } - - l.dirtyViewport = true -} - -// SelectNext selects the next item. -func (l *LazyList) SelectNext() { - if len(l.items) == 0 { - return - } - - if l.selectedIdx < 0 { - l.selectedIdx = 0 - } else if l.selectedIdx < len(l.items)-1 { - l.selectedIdx++ - } - - l.dirtyViewport = true -} - -// SelectFirst selects the first item. -func (l *LazyList) SelectFirst() { - if len(l.items) > 0 { - l.selectedIdx = 0 - l.dirtyViewport = true - } -} - -// SelectLast selects the last item. -func (l *LazyList) SelectLast() { - if len(l.items) > 0 { - l.selectedIdx = len(l.items) - 1 - l.dirtyViewport = true - } -} - -// SelectFirstInView selects the first visible item in the viewport. -func (l *LazyList) SelectFirstInView() { - if len(l.items) == 0 { - return - } - - firstIdx, _ := l.findVisibleItems() - l.selectedIdx = firstIdx - l.dirtyViewport = true -} - -// SelectLastInView selects the last visible item in the viewport. -func (l *LazyList) SelectLastInView() { - if len(l.items) == 0 { - return - } - - _, lastIdx := l.findVisibleItems() - l.selectedIdx = lastIdx - l.dirtyViewport = true -} - -// SelectedItemInView returns whether the selected item is visible in the viewport. -func (l *LazyList) SelectedItemInView() bool { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return false - } - - firstIdx, lastIdx := l.findVisibleItems() - return l.selectedIdx >= firstIdx && l.selectedIdx <= lastIdx -} - -// ScrollToSelected scrolls the viewport to ensure the selected item is visible. -func (l *LazyList) ScrollToSelected() { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return - } - - // Get selected item position - itemY := l.getItemPosition(l.selectedIdx) - itemHeight := l.itemHeights[l.selectedIdx].height - - // Check if item is above viewport - if itemY < l.offset { - l.offset = itemY - l.dirtyViewport = true - return - } - - // Check if item is below viewport - itemBottom := itemY + itemHeight - viewportBottom := l.offset + l.height - - if itemBottom > viewportBottom { - // Scroll so item bottom is at viewport bottom - l.offset = itemBottom - l.height - l.clampOffset() - l.dirtyViewport = true - } -} - -// Mouse interaction methods - -// HandleMouseDown handles mouse button down events. -// Returns true if the event was handled. -func (l *LazyList) HandleMouseDown(x, y int) bool { - if x < 0 || y < 0 || x >= l.width || y >= l.height { - return false - } - - // Find which item was clicked - clickY := l.offset + y - itemIdx := l.findItemAtY(clickY) - - if itemIdx < 0 { - return false - } - - // Calculate item-relative Y position. - itemY := clickY - l.getItemPosition(itemIdx) - - l.mouseDown = true - l.mouseDownItem = itemIdx - l.mouseDownX = x - l.mouseDownY = itemY - l.mouseDragItem = itemIdx - l.mouseDragX = x - l.mouseDragY = itemY - - // Select the clicked item - l.SetSelected(itemIdx) - - return true -} - -// HandleMouseDrag handles mouse drag events. -func (l *LazyList) HandleMouseDrag(x, y int) { - if !l.mouseDown { - return - } - - // Find item under cursor - if y >= 0 && y < l.height { - dragY := l.offset + y - itemIdx := l.findItemAtY(dragY) - if itemIdx >= 0 { - l.mouseDragItem = itemIdx - // Calculate item-relative Y position. - l.mouseDragY = dragY - l.getItemPosition(itemIdx) - l.mouseDragX = x - } - } - - // Update highlight if item supports it. - l.updateHighlight() -} - -// HandleMouseUp handles mouse button up events. -func (l *LazyList) HandleMouseUp(x, y int) { - if !l.mouseDown { - return - } - - l.mouseDown = false - - // Final highlight update. - l.updateHighlight() -} - -// findItemAtY finds the item index at the given Y coordinate (in content space, not viewport). -func (l *LazyList) findItemAtY(y int) int { - if y < 0 || len(l.items) == 0 { - return -1 - } - - pos := 0 - for i := 0; i < len(l.items); i++ { - itemHeight := l.itemHeights[i].height - if y >= pos && y < pos+itemHeight { - return i - } - pos += itemHeight - } - - return -1 -} - -// updateHighlight updates the highlight range for highlightable items. -// Supports highlighting within a single item and respects drag direction. -func (l *LazyList) updateHighlight() { - if l.mouseDownItem < 0 { - return - } - - // Get start and end item indices. - downItemIdx := l.mouseDownItem - dragItemIdx := l.mouseDragItem - - // Determine selection direction. - draggingDown := dragItemIdx > downItemIdx || - (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) || - (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX) - - // Determine actual start and end based on direction. - var startItemIdx, endItemIdx int - var startLine, startCol, endLine, endCol int - - if draggingDown { - // Normal forward selection. - startItemIdx = downItemIdx - endItemIdx = dragItemIdx - startLine = l.mouseDownY - startCol = l.mouseDownX - endLine = l.mouseDragY - endCol = l.mouseDragX - } else { - // Backward selection (dragging up). - startItemIdx = dragItemIdx - endItemIdx = downItemIdx - startLine = l.mouseDragY - startCol = l.mouseDragX - endLine = l.mouseDownY - endCol = l.mouseDownX - } - - // Clear all highlights first. - for i, item := range l.items { - if h, ok := item.(Highlightable); ok { - h.SetHighlight(-1, -1, -1, -1) - delete(l.renderedCache, i) - l.dirtyItems[i] = true - } - } - - // Highlight all items in range. - for idx := startItemIdx; idx <= endItemIdx; idx++ { - item, ok := l.items[idx].(Highlightable) - if !ok { - continue - } - - if idx == startItemIdx && idx == endItemIdx { - // Single item selection. - item.SetHighlight(startLine, startCol, endLine, endCol) - } else if idx == startItemIdx { - // First item - from start position to end of item. - itemHeight := l.itemHeights[idx].height - item.SetHighlight(startLine, startCol, itemHeight-1, 9999) // 9999 = end of line - } else if idx == endItemIdx { - // Last item - from start of item to end position. - item.SetHighlight(0, 0, endLine, endCol) - } else { - // Middle item - fully highlighted. - itemHeight := l.itemHeights[idx].height - item.SetHighlight(0, 0, itemHeight-1, 9999) - } - - delete(l.renderedCache, idx) - l.dirtyItems[idx] = true - } -} - -// ClearHighlight clears any active text highlighting. -func (l *LazyList) ClearHighlight() { - for i, item := range l.items { - if h, ok := item.(Highlightable); ok { - h.SetHighlight(-1, -1, -1, -1) - delete(l.renderedCache, i) - l.dirtyItems[i] = true - } - } - l.mouseDownItem = -1 - l.mouseDragItem = -1 -} - -// GetHighlightedText returns the plain text content of all highlighted regions -// across items, without any styling. Returns empty string if no highlights exist. -func (l *LazyList) GetHighlightedText() string { - var result strings.Builder - - // Iterate through items to find highlighted ones. - for i, item := range l.items { - h, ok := item.(Highlightable) - if !ok { - continue - } - - startLine, startCol, endLine, endCol := h.GetHighlight() - if startLine < 0 { - continue - } - - // Ensure item is rendered so we can access its buffer. - if _, ok := l.renderedCache[i]; !ok { - l.renderItem(i) - } - - cached := l.renderedCache[i] - if cached == nil || cached.buffer == nil { - continue - } - - buf := cached.buffer - itemHeight := cached.height - - // Extract text from highlighted region in item buffer. - for y := startLine; y <= endLine && y < itemHeight; y++ { - if y >= buf.Height() { - break - } - - line := buf.Line(y) - - // Determine column range for this line. - colStart := 0 - if y == startLine { - colStart = startCol - } - - colEnd := len(line) - if y == endLine { - colEnd = min(endCol, len(line)) - } - - // Track last non-empty position to trim trailing spaces. - lastContentX := -1 - for x := colStart; x < colEnd && x < len(line); x++ { - cell := line.At(x) - if cell == nil || cell.IsZero() { - continue - } - if cell.Content != "" && cell.Content != " " { - lastContentX = x - } - } - - // Extract text from cells, up to last content. - endX := colEnd - if lastContentX >= 0 { - endX = lastContentX + 1 - } - - for x := colStart; x < endX && x < len(line); x++ { - cell := line.At(x) - if cell != nil && !cell.IsZero() { - result.WriteString(cell.Content) - } - } - - // Add newline if not the last line. - if y < endLine { - result.WriteString("\n") - } - } - - // Add newline between items if this isn't the last highlighted item. - if i < len(l.items)-1 { - nextHasHighlight := false - for j := i + 1; j < len(l.items); j++ { - if h, ok := l.items[j].(Highlightable); ok { - s, _, _, _ := h.GetHighlight() - if s >= 0 { - nextHasHighlight = true - break - } - } - } - if nextHasHighlight { - result.WriteString("\n") - } - } - } - - return result.String() -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go deleted file mode 100644 index 7cde0f879ae3705421d5a6a27fd04c87a59ac0bc..0000000000000000000000000000000000000000 --- a/internal/ui/list/list.go +++ /dev/null @@ -1,1126 +0,0 @@ -package list - -import ( - "strings" - - uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/ultraviolet/screen" - "github.com/charmbracelet/x/exp/ordered" -) - -// List is a scrollable list component that implements uv.Drawable. -// It efficiently manages a large number of items by caching rendered content -// in a master buffer and extracting only the visible viewport when drawn. -type List struct { - // Configuration - width, height int - - // Data - items []Item - - // Focus & Selection - focused bool - selectedIdx int // Currently selected item index (-1 if none) - - // Master buffer containing ALL rendered items - masterBuffer *uv.ScreenBuffer - totalHeight int - - // Item positioning in master buffer - itemPositions []itemPosition - - // Viewport state - offset int // Scroll offset in lines from top - - // Mouse state - mouseDown bool - mouseDownItem int // Item index where mouse was pressed - mouseDownX int // X position in item content (character offset) - mouseDownY int // Y position in item (line offset) - mouseDragItem int // Current item index being dragged over - mouseDragX int // Current X in item content - mouseDragY int // Current Y in item - - // Dirty tracking - dirty bool - dirtyItems map[int]bool -} - -type itemPosition struct { - startLine int - height int -} - -// New creates a new list with the given items. -func New(items ...Item) *List { - l := &List{ - items: items, - itemPositions: make([]itemPosition, len(items)), - dirtyItems: make(map[int]bool), - selectedIdx: -1, - mouseDownItem: -1, - mouseDragItem: -1, - } - - l.dirty = true - return l -} - -// ensureBuilt ensures the master buffer is built. -// This is called by methods that need itemPositions or totalHeight. -func (l *List) ensureBuilt() { - if l.width <= 0 || l.height <= 0 { - return - } - - if l.dirty { - l.rebuildMasterBuffer() - } else if len(l.dirtyItems) > 0 { - l.updateDirtyItems() - } -} - -// Draw implements uv.Drawable. -// Draws the visible viewport of the list to the given screen buffer. -func (l *List) Draw(scr uv.Screen, area uv.Rectangle) { - if area.Dx() <= 0 || area.Dy() <= 0 { - return - } - - // Update internal dimensions if area size changed - widthChanged := l.width != area.Dx() - heightChanged := l.height != area.Dy() - - l.width = area.Dx() - l.height = area.Dy() - - // Only width changes require rebuilding master buffer - // Height changes only affect viewport clipping, not item rendering - if widthChanged { - l.dirty = true - } - - // Height changes require clamping offset to new bounds - if heightChanged { - l.clampOffset() - } - - if len(l.items) == 0 { - screen.ClearArea(scr, area) - return - } - - // Ensure buffer is built - l.ensureBuilt() - - // Draw visible portion to the target screen - l.drawViewport(scr, area) -} - -// Render renders the visible viewport to a string. -// This is a convenience method that creates a temporary screen buffer, -// draws to it, and returns the rendered string. -func (l *List) Render() string { - if l.width <= 0 || l.height <= 0 { - return "" - } - - if len(l.items) == 0 { - return "" - } - - // Ensure buffer is built - l.ensureBuilt() - - // Extract visible lines directly from master buffer - return l.renderViewport() -} - -// renderViewport renders the visible portion of the master buffer to a string. -func (l *List) renderViewport() string { - if l.masterBuffer == nil { - return "" - } - - buf := l.masterBuffer.Buffer - - // Calculate visible region in master buffer - srcStartY := l.offset - srcEndY := l.offset + l.height - - // Clamp to actual buffer bounds - if srcStartY >= len(buf.Lines) { - // Beyond end of content, return empty lines - emptyLine := strings.Repeat(" ", l.width) - lines := make([]string, l.height) - for i := range lines { - lines[i] = emptyLine - } - return strings.Join(lines, "\n") - } - if srcEndY > len(buf.Lines) { - srcEndY = len(buf.Lines) - } - - // Build result with proper line handling - lines := make([]string, l.height) - lineIdx := 0 - - // Render visible lines from buffer - for y := srcStartY; y < srcEndY && lineIdx < l.height; y++ { - lines[lineIdx] = buf.Lines[y].Render() - lineIdx++ - } - - // Pad remaining lines with spaces to maintain viewport height - emptyLine := strings.Repeat(" ", l.width) - for ; lineIdx < l.height; lineIdx++ { - lines[lineIdx] = emptyLine - } - - return strings.Join(lines, "\n") -} - -// drawViewport draws the visible portion from master buffer to target screen. -func (l *List) drawViewport(scr uv.Screen, area uv.Rectangle) { - if l.masterBuffer == nil { - screen.ClearArea(scr, area) - return - } - - buf := l.masterBuffer.Buffer - - // Calculate visible region in master buffer - srcStartY := l.offset - srcEndY := l.offset + area.Dy() - - // Clamp to actual buffer bounds - if srcStartY >= buf.Height() { - screen.ClearArea(scr, area) - return - } - if srcEndY > buf.Height() { - srcEndY = buf.Height() - } - - // Copy visible lines to target screen - destY := area.Min.Y - for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ { - line := buf.Line(srcY) - destX := area.Min.X - - for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ { - cell := line.At(x) - scr.SetCell(destX, destY, cell) - destX++ - } - destY++ - } - - // Clear any remaining area if content is shorter than viewport - if destY < area.Max.Y { - clearArea := uv.Rect(area.Min.X, destY, area.Dx(), area.Max.Y-destY) - screen.ClearArea(scr, clearArea) - } -} - -// rebuildMasterBuffer composes all items into the master buffer. -func (l *List) rebuildMasterBuffer() { - if len(l.items) == 0 { - l.totalHeight = 0 - l.dirty = false - return - } - - // Calculate total height - l.totalHeight = l.calculateTotalHeight() - - // Create or resize master buffer - if l.masterBuffer == nil || l.masterBuffer.Width() != l.width || l.masterBuffer.Height() != l.totalHeight { - buf := uv.NewScreenBuffer(l.width, l.totalHeight) - l.masterBuffer = &buf - } - - // Clear buffer - screen.Clear(l.masterBuffer) - - // Draw each item - currentY := 0 - for i, item := range l.items { - itemHeight := item.Height(l.width) - - // Draw item to master buffer - area := uv.Rect(0, currentY, l.width, itemHeight) - item.Draw(l.masterBuffer, area) - - // Store position - l.itemPositions[i] = itemPosition{ - startLine: currentY, - height: itemHeight, - } - - // Advance position - currentY += itemHeight - } - - l.dirty = false - l.dirtyItems = make(map[int]bool) -} - -// updateDirtyItems efficiently updates only changed items using slice operations. -func (l *List) updateDirtyItems() { - if len(l.dirtyItems) == 0 { - return - } - - // Check if all dirty items have unchanged heights - allSameHeight := true - for idx := range l.dirtyItems { - item := l.items[idx] - pos := l.itemPositions[idx] - newHeight := item.Height(l.width) - if newHeight != pos.height { - allSameHeight = false - break - } - } - - // Optimization: If all dirty items have unchanged heights, re-render in place - if allSameHeight { - buf := l.masterBuffer.Buffer - for idx := range l.dirtyItems { - item := l.items[idx] - pos := l.itemPositions[idx] - - // Clear the item's area - for y := pos.startLine; y < pos.startLine+pos.height && y < len(buf.Lines); y++ { - buf.Lines[y] = uv.NewLine(l.width) - } - - // Re-render item - area := uv.Rect(0, pos.startLine, l.width, pos.height) - item.Draw(l.masterBuffer, area) - } - - l.dirtyItems = make(map[int]bool) - return - } - - // Height changed - full rebuild - l.dirty = true - l.dirtyItems = make(map[int]bool) - l.rebuildMasterBuffer() -} - -// updatePositionsBelow updates the startLine for all items below the given index. -func (l *List) updatePositionsBelow(fromIdx int, delta int) { - for i := fromIdx + 1; i < len(l.items); i++ { - pos := l.itemPositions[i] - pos.startLine += delta - l.itemPositions[i] = pos - } -} - -// calculateTotalHeight calculates the total height of all items plus gaps. -func (l *List) calculateTotalHeight() int { - if len(l.items) == 0 { - return 0 - } - - total := 0 - for _, item := range l.items { - total += item.Height(l.width) - } - return total -} - -// SetSize updates the viewport size. -func (l *List) SetSize(width, height int) { - widthChanged := l.width != width - heightChanged := l.height != height - - l.width = width - l.height = height - - // Width changes require full rebuild (items may reflow) - if widthChanged { - l.dirty = true - } - - // Height changes require clamping offset to new bounds - if heightChanged { - l.clampOffset() - } -} - -// Height returns the current viewport height. -func (l *List) Height() int { - return l.height -} - -// Width returns the current viewport width. -func (l *List) Width() int { - return l.width -} - -// GetSize returns the current viewport size. -func (l *List) GetSize() (int, int) { - return l.width, l.height -} - -// Len returns the number of items in the list. -func (l *List) Len() int { - return len(l.items) -} - -// SetItems replaces all items in the list. -func (l *List) SetItems(items []Item) { - l.items = items - l.itemPositions = make([]itemPosition, len(items)) - l.dirty = true -} - -// Items returns all items in the list. -func (l *List) Items() []Item { - return l.items -} - -// AppendItem adds an item to the end of the list. Returns true if successful. -func (l *List) AppendItem(item Item) bool { - l.items = append(l.items, item) - l.itemPositions = append(l.itemPositions, itemPosition{}) - - // If buffer not built yet, mark dirty for full rebuild - if l.masterBuffer == nil || l.width <= 0 { - l.dirty = true - return true - } - - // Process any pending dirty items before modifying buffer structure - if len(l.dirtyItems) > 0 { - l.updateDirtyItems() - } - - // Efficient append: insert lines at end of buffer - itemHeight := item.Height(l.width) - startLine := l.totalHeight - - // Expand buffer - newLines := make([]uv.Line, itemHeight) - for i := range newLines { - newLines[i] = uv.NewLine(l.width) - } - l.masterBuffer.Buffer.Lines = append(l.masterBuffer.Buffer.Lines, newLines...) - - // Draw new item - area := uv.Rect(0, startLine, l.width, itemHeight) - item.Draw(l.masterBuffer, area) - - // Update tracking - l.itemPositions[len(l.items)-1] = itemPosition{ - startLine: startLine, - height: itemHeight, - } - l.totalHeight += itemHeight - - return true -} - -// PrependItem adds an item to the beginning of the list. Returns true if -// successful. -func (l *List) PrependItem(item Item) bool { - l.items = append([]Item{item}, l.items...) - l.itemPositions = append([]itemPosition{{}}, l.itemPositions...) - if l.selectedIdx >= 0 { - l.selectedIdx++ - } - - // If buffer not built yet, mark dirty for full rebuild - if l.masterBuffer == nil || l.width <= 0 { - l.dirty = true - return true - } - - // Process any pending dirty items before modifying buffer structure - if len(l.dirtyItems) > 0 { - l.updateDirtyItems() - } - - // Efficient prepend: insert lines at start of buffer - itemHeight := item.Height(l.width) - - // Create new lines - newLines := make([]uv.Line, itemHeight) - for i := range newLines { - newLines[i] = uv.NewLine(l.width) - } - - // Insert at beginning - buf := l.masterBuffer.Buffer - buf.Lines = append(newLines, buf.Lines...) - - // Draw new item - area := uv.Rect(0, 0, l.width, itemHeight) - item.Draw(l.masterBuffer, area) - - // Update all positions (shift everything down) - for i := range l.itemPositions { - pos := l.itemPositions[i] - pos.startLine += itemHeight - l.itemPositions[i] = pos - } - - // Add position for new item at start - l.itemPositions[0] = itemPosition{ - startLine: 0, - height: itemHeight, - } - - l.totalHeight += itemHeight - - return true -} - -// UpdateItem replaces an item with the same index. Returns true if successful. -func (l *List) UpdateItem(idx int, item Item) bool { - if idx < 0 || idx >= len(l.items) { - return false - } - l.items[idx] = item - l.dirtyItems[idx] = true - return true -} - -// DeleteItem removes an item by index. Returns true if successful. -func (l *List) DeleteItem(idx int) bool { - if idx < 0 || idx >= len(l.items) { - return false - } - - // Get position before deleting - pos := l.itemPositions[idx] - - // Process any pending dirty items before modifying buffer structure - if len(l.dirtyItems) > 0 { - l.updateDirtyItems() - } - - l.items = append(l.items[:idx], l.items[idx+1:]...) - l.itemPositions = append(l.itemPositions[:idx], l.itemPositions[idx+1:]...) - - // Adjust selection - if l.selectedIdx == idx { - if idx > 0 { - l.selectedIdx = idx - 1 - } else if len(l.items) > 0 { - l.selectedIdx = 0 - } else { - l.selectedIdx = -1 - } - } else if l.selectedIdx > idx { - l.selectedIdx-- - } - - // If buffer not built yet, mark dirty for full rebuild - if l.masterBuffer == nil { - l.dirty = true - return true - } - - // Efficient delete: remove lines from buffer - deleteStart := pos.startLine - deleteEnd := pos.startLine + pos.height - buf := l.masterBuffer.Buffer - - if deleteEnd <= len(buf.Lines) { - buf.Lines = append(buf.Lines[:deleteStart], buf.Lines[deleteEnd:]...) - l.totalHeight -= pos.height - l.updatePositionsBelow(idx-1, -pos.height) - } else { - // Position data corrupt, rebuild - l.dirty = true - } - - return true -} - -// Focus focuses the list and the selected item (if focusable). -func (l *List) Focus() { - l.focused = true - l.focusSelectedItem() -} - -// Blur blurs the list and the selected item (if focusable). -func (l *List) Blur() { - l.focused = false - l.blurSelectedItem() -} - -// Focused returns whether the list is focused. -func (l *List) Focused() bool { - return l.focused -} - -// SetSelected sets the selected item by ID. -func (l *List) SetSelected(idx int) { - if idx < 0 || idx >= len(l.items) { - return - } - if l.selectedIdx == idx { - return - } - - prevIdx := l.selectedIdx - l.selectedIdx = idx - - // Update focus states if list is focused - if l.focused { - if prevIdx >= 0 && prevIdx < len(l.items) { - if f, ok := l.items[prevIdx].(Focusable); ok { - f.Blur() - l.dirtyItems[prevIdx] = true - } - } - - if f, ok := l.items[idx].(Focusable); ok { - f.Focus() - l.dirtyItems[idx] = true - } - } -} - -// SelectFirst selects the first item in the list. -func (l *List) SelectFirst() { - l.SetSelected(0) -} - -// SelectLast selects the last item in the list. -func (l *List) SelectLast() { - l.SetSelected(len(l.items) - 1) -} - -// SelectNextWrap selects the next item in the list (wraps to beginning). -// When the list is focused, skips non-focusable items. -func (l *List) SelectNextWrap() { - l.selectNext(true) -} - -// SelectNext selects the next item in the list (no wrap). -// When the list is focused, skips non-focusable items. -func (l *List) SelectNext() { - l.selectNext(false) -} - -func (l *List) selectNext(wrap bool) { - if len(l.items) == 0 { - return - } - - startIdx := l.selectedIdx - for i := 0; i < len(l.items); i++ { - var nextIdx int - if wrap { - nextIdx = (startIdx + 1 + i) % len(l.items) - } else { - nextIdx = startIdx + 1 + i - if nextIdx >= len(l.items) { - return - } - } - - // If list is focused and item is not focusable, skip it - if l.focused { - if _, ok := l.items[nextIdx].(Focusable); !ok { - continue - } - } - - // Select and scroll to this item - l.SetSelected(nextIdx) - return - } -} - -// SelectPrevWrap selects the previous item in the list (wraps to end). -// When the list is focused, skips non-focusable items. -func (l *List) SelectPrevWrap() { - l.selectPrev(true) -} - -// SelectPrev selects the previous item in the list (no wrap). -// When the list is focused, skips non-focusable items. -func (l *List) SelectPrev() { - l.selectPrev(false) -} - -func (l *List) selectPrev(wrap bool) { - if len(l.items) == 0 { - return - } - - startIdx := l.selectedIdx - for i := 0; i < len(l.items); i++ { - var prevIdx int - if wrap { - prevIdx = (startIdx - 1 - i + len(l.items)) % len(l.items) - } else { - prevIdx = startIdx - 1 - i - if prevIdx < 0 { - return - } - } - - // If list is focused and item is not focusable, skip it - if l.focused { - if _, ok := l.items[prevIdx].(Focusable); !ok { - continue - } - } - - // Select and scroll to this item - l.SetSelected(prevIdx) - return - } -} - -// SelectedItem returns the currently selected item, or nil if none. -func (l *List) SelectedItem() Item { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return nil - } - return l.items[l.selectedIdx] -} - -// SelectedIndex returns the index of the currently selected item, or -1 if none. -func (l *List) SelectedIndex() int { - return l.selectedIdx -} - -// AtBottom returns whether the viewport is scrolled to the bottom. -func (l *List) AtBottom() bool { - l.ensureBuilt() - return l.offset >= l.totalHeight-l.height -} - -// AtTop returns whether the viewport is scrolled to the top. -func (l *List) AtTop() bool { - return l.offset <= 0 -} - -// ScrollBy scrolls the viewport by the given number of lines. -// Positive values scroll down, negative scroll up. -func (l *List) ScrollBy(deltaLines int) { - l.offset += deltaLines - l.clampOffset() -} - -// ScrollToTop scrolls to the top of the list. -func (l *List) ScrollToTop() { - l.offset = 0 -} - -// ScrollToBottom scrolls to the bottom of the list. -func (l *List) ScrollToBottom() { - l.ensureBuilt() - if l.totalHeight > l.height { - l.offset = l.totalHeight - l.height - } else { - l.offset = 0 - } -} - -// ScrollToItem scrolls to make the item with the given ID visible. -func (l *List) ScrollToItem(idx int) { - l.ensureBuilt() - pos := l.itemPositions[idx] - itemStart := pos.startLine - itemEnd := pos.startLine + pos.height - viewStart := l.offset - viewEnd := l.offset + l.height - - // Check if item is already fully visible - if itemStart >= viewStart && itemEnd <= viewEnd { - return - } - - // Scroll to show item - if itemStart < viewStart { - l.offset = itemStart - } else if itemEnd > viewEnd { - l.offset = itemEnd - l.height - } - - l.clampOffset() -} - -// ScrollToSelected scrolls to make the selected item visible. -func (l *List) ScrollToSelected() { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return - } - l.ScrollToItem(l.selectedIdx) -} - -// Offset returns the current scroll offset. -func (l *List) Offset() int { - return l.offset -} - -// TotalHeight returns the total height of all items including gaps. -func (l *List) TotalHeight() int { - return l.totalHeight -} - -// SelectFirstInView selects the first item that is fully visible in the viewport. -func (l *List) SelectFirstInView() { - l.ensureBuilt() - - viewportStart := l.offset - viewportEnd := l.offset + l.height - - for i := range l.items { - pos := l.itemPositions[i] - - // Check if item is fully within viewport bounds - if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd { - l.SetSelected(i) - return - } - } -} - -// SelectLastInView selects the last item that is fully visible in the viewport. -func (l *List) SelectLastInView() { - l.ensureBuilt() - - viewportStart := l.offset - viewportEnd := l.offset + l.height - - for i := len(l.items) - 1; i >= 0; i-- { - pos := l.itemPositions[i] - - // Check if item is fully within viewport bounds - if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd { - l.SetSelected(i) - return - } - } -} - -// SelectedItemInView returns true if the selected item is currently visible in the viewport. -func (l *List) SelectedItemInView() bool { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return false - } - - // Get selected item ID and position - pos := l.itemPositions[l.selectedIdx] - - // Check if item is within viewport bounds - viewportStart := l.offset - viewportEnd := l.offset + l.height - - // Item is visible if any part of it overlaps with the viewport - return pos.startLine < viewportEnd && (pos.startLine+pos.height) > viewportStart -} - -// clampOffset ensures offset is within valid bounds. -func (l *List) clampOffset() { - l.offset = ordered.Clamp(l.offset, 0, l.totalHeight-l.height) -} - -// focusSelectedItem focuses the currently selected item if it's focusable. -func (l *List) focusSelectedItem() { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return - } - - item := l.items[l.selectedIdx] - if f, ok := item.(Focusable); ok { - f.Focus() - l.dirtyItems[l.selectedIdx] = true - } -} - -// blurSelectedItem blurs the currently selected item if it's focusable. -func (l *List) blurSelectedItem() { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return - } - - item := l.items[l.selectedIdx] - if f, ok := item.(Focusable); ok { - f.Blur() - l.dirtyItems[l.selectedIdx] = true - } -} - -// HandleMouseDown handles mouse button press events. -// x and y are viewport-relative coordinates (0,0 = top-left of visible area). -// Returns true if the event was handled. -func (l *List) HandleMouseDown(x, y int) bool { - l.ensureBuilt() - - // Convert viewport y to master buffer y - bufferY := y + l.offset - - // Find which item was clicked - itemIdx, itemY := l.findItemAtPosition(bufferY) - if itemIdx < 0 { - return false - } - - // Calculate x position within item content - // For now, x is just the viewport x coordinate - // Items can interpret this as character offset in their content - - l.mouseDown = true - l.mouseDownItem = itemIdx - l.mouseDownX = x - l.mouseDownY = itemY - l.mouseDragItem = itemIdx - l.mouseDragX = x - l.mouseDragY = itemY - - // Select the clicked item - l.SetSelected(itemIdx) - - return true -} - -// HandleMouseDrag handles mouse drag events during selection. -// x and y are viewport-relative coordinates. -// Returns true if the event was handled. -func (l *List) HandleMouseDrag(x, y int) bool { - if !l.mouseDown { - return false - } - - l.ensureBuilt() - - // Convert viewport y to master buffer y - bufferY := y + l.offset - - // Find which item we're dragging over - itemIdx, itemY := l.findItemAtPosition(bufferY) - if itemIdx < 0 { - return false - } - - l.mouseDragItem = itemIdx - l.mouseDragX = x - l.mouseDragY = itemY - - // Update highlight if item supports it - l.updateHighlight() - - return true -} - -// HandleMouseUp handles mouse button release events. -// Returns true if the event was handled. -func (l *List) HandleMouseUp(x, y int) bool { - if !l.mouseDown { - return false - } - - l.mouseDown = false - - // Final highlight update - l.updateHighlight() - - return true -} - -// ClearHighlight clears any active text highlighting. -func (l *List) ClearHighlight() { - for i, item := range l.items { - if h, ok := item.(Highlightable); ok { - h.SetHighlight(-1, -1, -1, -1) - l.dirtyItems[i] = true - } - } - l.mouseDownItem = -1 - l.mouseDragItem = -1 -} - -// findItemAtPosition finds the item at the given master buffer y coordinate. -// Returns the item index and the y offset within that item. It returns -1, -1 -// if no item is found. -func (l *List) findItemAtPosition(bufferY int) (itemIdx int, itemY int) { - if bufferY < 0 || bufferY >= l.totalHeight { - return -1, -1 - } - - // Linear search through items to find which one contains this y - // This could be optimized with binary search if needed - for i := range l.items { - pos := l.itemPositions[i] - if bufferY >= pos.startLine && bufferY < pos.startLine+pos.height { - return i, bufferY - pos.startLine - } - } - - return -1, -1 -} - -// updateHighlight updates the highlight range for highlightable items. -// Supports highlighting across multiple items and respects drag direction. -func (l *List) updateHighlight() { - if l.mouseDownItem < 0 { - return - } - - // Get start and end item indices - downItemIdx := l.mouseDownItem - dragItemIdx := l.mouseDragItem - - // Determine selection direction - draggingDown := dragItemIdx > downItemIdx || - (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) || - (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX) - - // Determine actual start and end based on direction - var startItemIdx, endItemIdx int - var startLine, startCol, endLine, endCol int - - if draggingDown { - // Normal forward selection - startItemIdx = downItemIdx - endItemIdx = dragItemIdx - startLine = l.mouseDownY - startCol = l.mouseDownX - endLine = l.mouseDragY - endCol = l.mouseDragX - } else { - // Backward selection (dragging up) - startItemIdx = dragItemIdx - endItemIdx = downItemIdx - startLine = l.mouseDragY - startCol = l.mouseDragX - endLine = l.mouseDownY - endCol = l.mouseDownX - } - - // Clear all highlights first - for i, item := range l.items { - if h, ok := item.(Highlightable); ok { - h.SetHighlight(-1, -1, -1, -1) - l.dirtyItems[i] = true - } - } - - // Highlight all items in range - for idx := startItemIdx; idx <= endItemIdx; idx++ { - item, ok := l.items[idx].(Highlightable) - if !ok { - continue - } - - if idx == startItemIdx && idx == endItemIdx { - // Single item selection - item.SetHighlight(startLine, startCol, endLine, endCol) - } else if idx == startItemIdx { - // First item - from start position to end of item - pos := l.itemPositions[idx] - item.SetHighlight(startLine, startCol, pos.height-1, 9999) // 9999 = end of line - } else if idx == endItemIdx { - // Last item - from start of item to end position - item.SetHighlight(0, 0, endLine, endCol) - } else { - // Middle item - fully highlighted - pos := l.itemPositions[idx] - item.SetHighlight(0, 0, pos.height-1, 9999) - } - - l.dirtyItems[idx] = true - } -} - -// GetHighlightedText returns the plain text content of all highlighted regions -// across items, without any styling. Returns empty string if no highlights exist. -func (l *List) GetHighlightedText() string { - l.ensureBuilt() - - if l.masterBuffer == nil { - return "" - } - - var result strings.Builder - - // Iterate through items to find highlighted ones - for i, item := range l.items { - h, ok := item.(Highlightable) - if !ok { - continue - } - - startLine, startCol, endLine, endCol := h.GetHighlight() - if startLine < 0 { - continue - } - - pos := l.itemPositions[i] - - // Extract text from highlighted region in master buffer - for y := startLine; y <= endLine && y < pos.height; y++ { - bufferY := pos.startLine + y - if bufferY >= l.masterBuffer.Height() { - break - } - - line := l.masterBuffer.Line(bufferY) - - // Determine column range for this line - colStart := 0 - if y == startLine { - colStart = startCol - } - - colEnd := len(line) - if y == endLine { - colEnd = min(endCol, len(line)) - } - - // Track last non-empty position to trim trailing spaces - lastContentX := -1 - for x := colStart; x < colEnd && x < len(line); x++ { - cell := line.At(x) - if cell == nil || cell.IsZero() { - continue - } - if cell.Content != "" && cell.Content != " " { - lastContentX = x - } - } - - // Extract text from cells using String() method, up to last content - endX := colEnd - if lastContentX >= 0 { - endX = lastContentX + 1 - } - - for x := colStart; x < endX && x < len(line); x++ { - cell := line.At(x) - if cell == nil || cell.IsZero() { - continue - } - result.WriteString(cell.String()) - } - - // Add newline between lines (but not after the last line) - if y < endLine && y < pos.height-1 { - result.WriteRune('\n') - } - } - - // Add newline between items if there are more highlighted items - if result.Len() > 0 { - result.WriteRune('\n') - } - } - - // Trim trailing newline if present - text := result.String() - return strings.TrimSuffix(text, "\n") -} diff --git a/internal/ui/list/list_test.go b/internal/ui/list/list_test.go deleted file mode 100644 index ac2a64e5e06879340ec8da93d273dfd53882e60e..0000000000000000000000000000000000000000 --- a/internal/ui/list/list_test.go +++ /dev/null @@ -1,578 +0,0 @@ -package list - -import ( - "strings" - "testing" - - "charm.land/lipgloss/v2" - uv "github.com/charmbracelet/ultraviolet" - "github.com/stretchr/testify/require" -) - -func TestNewList(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - } - - l := New(items...) - l.SetSize(80, 24) - - if len(l.items) != 3 { - t.Errorf("expected 3 items, got %d", len(l.items)) - } - - if l.width != 80 || l.height != 24 { - t.Errorf("expected size 80x24, got %dx%d", l.width, l.height) - } -} - -func TestListDraw(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - } - - l := New(items...) - l.SetSize(80, 10) - - // Create a screen buffer to draw into - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - - // Draw the list - l.Draw(&screen, area) - - // Verify the buffer has content - output := screen.Render() - if len(output) == 0 { - t.Error("expected non-empty output") - } -} - -func TestListAppendItem(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - } - - l := New(items...) - l.AppendItem(NewStringItem("Item 2")) - - if len(l.items) != 2 { - t.Errorf("expected 2 items after append, got %d", len(l.items)) - } -} - -func TestListDeleteItem(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - } - - l := New(items...) - l.DeleteItem(2) - - if len(l.items) != 2 { - t.Errorf("expected 2 items after delete, got %d", len(l.items)) - } -} - -func TestListUpdateItem(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - } - - l := New(items...) - l.SetSize(80, 10) - - // Update item - newItem := NewStringItem("Updated Item 2") - l.UpdateItem(1, newItem) - - if l.items[1].(*StringItem).content != "Updated Item 2" { - t.Errorf("expected updated content, got '%s'", l.items[1].(*StringItem).content) - } -} - -func TestListSelection(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - } - - l := New(items...) - l.SetSelected(0) - - if l.SelectedIndex() != 0 { - t.Errorf("expected selected index 0, got %d", l.SelectedIndex()) - } - - l.SelectNext() - if l.SelectedIndex() != 1 { - t.Errorf("expected selected index 1 after SelectNext, got %d", l.SelectedIndex()) - } - - l.SelectPrev() - if l.SelectedIndex() != 0 { - t.Errorf("expected selected index 0 after SelectPrev, got %d", l.SelectedIndex()) - } -} - -func TestListScrolling(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - NewStringItem("Item 4"), - NewStringItem("Item 5"), - } - - l := New(items...) - l.SetSize(80, 2) // Small viewport - - // Draw to initialize the master buffer - screen := uv.NewScreenBuffer(80, 2) - area := uv.Rect(0, 0, 80, 2) - l.Draw(&screen, area) - - if l.Offset() != 0 { - t.Errorf("expected initial offset 0, got %d", l.Offset()) - } - - l.ScrollBy(2) - if l.Offset() != 2 { - t.Errorf("expected offset 2 after ScrollBy(2), got %d", l.Offset()) - } - - l.ScrollToTop() - if l.Offset() != 0 { - t.Errorf("expected offset 0 after ScrollToTop, got %d", l.Offset()) - } -} - -// FocusableTestItem is a test item that implements Focusable. -type FocusableTestItem struct { - id string - content string - focused bool -} - -func (f *FocusableTestItem) ID() string { - return f.id -} - -func (f *FocusableTestItem) Height(width int) int { - return 1 -} - -func (f *FocusableTestItem) Draw(scr uv.Screen, area uv.Rectangle) { - prefix := "[ ]" - if f.focused { - prefix = "[X]" - } - content := prefix + " " + f.content - styled := uv.NewStyledString(content) - styled.Draw(scr, area) -} - -func (f *FocusableTestItem) Focus() { - f.focused = true -} - -func (f *FocusableTestItem) Blur() { - f.focused = false -} - -func (f *FocusableTestItem) IsFocused() bool { - return f.focused -} - -func TestListFocus(t *testing.T) { - items := []Item{ - &FocusableTestItem{id: "1", content: "Item 1"}, - &FocusableTestItem{id: "2", content: "Item 2"}, - } - - l := New(items...) - l.SetSize(80, 10) - l.SetSelected(0) - - // Focus the list - l.Focus() - - if !l.Focused() { - t.Error("expected list to be focused") - } - - // Check if selected item is focused - selectedItem := l.SelectedItem().(*FocusableTestItem) - if !selectedItem.IsFocused() { - t.Error("expected selected item to be focused") - } - - // Select next and check focus changes - l.SelectNext() - if selectedItem.IsFocused() { - t.Error("expected previous item to be blurred") - } - - newSelectedItem := l.SelectedItem().(*FocusableTestItem) - if !newSelectedItem.IsFocused() { - t.Error("expected new selected item to be focused") - } - - // Blur the list - l.Blur() - if l.Focused() { - t.Error("expected list to be blurred") - } -} - -// TestFocusNavigationAfterAppendingToViewportHeight reproduces the bug: -// Append items until viewport is full, select last, then navigate backwards. -func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) { - t.Parallel() - - focusStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("86")) - - blurStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("240")) - - // Start with one item - items := []Item{ - NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle), - } - - l := New(items...) - l.SetSize(20, 15) // 15 lines viewport height - l.SetSelected(0) - l.Focus() - - // Initial draw to build buffer - screen := uv.NewScreenBuffer(20, 15) - l.Draw(&screen, uv.Rect(0, 0, 20, 15)) - - // Append items until we exceed viewport height - // Each focusable item with border is 5 lines tall - for i := 2; i <= 4; i++ { - item := NewStringItem("Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle) - l.AppendItem(item) - } - - // Select the last item - l.SetSelected(3) - - // Draw - screen = uv.NewScreenBuffer(20, 15) - l.Draw(&screen, uv.Rect(0, 0, 20, 15)) - output := screen.Render() - - t.Logf("After selecting last item:\n%s", output) - require.Contains(t, output, "38;5;86", "expected focus color on last item") - - // Now navigate backwards - l.SelectPrev() - - screen = uv.NewScreenBuffer(20, 15) - l.Draw(&screen, uv.Rect(0, 0, 20, 15)) - output = screen.Render() - - t.Logf("After SelectPrev:\n%s", output) - require.Contains(t, output, "38;5;86", "expected focus color after SelectPrev") - - // Navigate backwards again - l.SelectPrev() - - screen = uv.NewScreenBuffer(20, 15) - l.Draw(&screen, uv.Rect(0, 0, 20, 15)) - output = screen.Render() - - t.Logf("After second SelectPrev:\n%s", output) - require.Contains(t, output, "38;5;86", "expected focus color after second SelectPrev") -} - -func TestFocusableItemUpdate(t *testing.T) { - // Create styles with borders - focusStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("86")) - - blurStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("240")) - - // Create a focusable item - item := NewStringItem("Test Item").WithFocusStyles(&focusStyle, &blurStyle) - - // Initially not focused - render with blur style - screen1 := uv.NewScreenBuffer(20, 5) - area := uv.Rect(0, 0, 20, 5) - item.Draw(&screen1, area) - output1 := screen1.Render() - - // Focus the item - item.Focus() - - // Render again - should show focus style - screen2 := uv.NewScreenBuffer(20, 5) - item.Draw(&screen2, area) - output2 := screen2.Render() - - // Outputs should be different (different border colors) - if output1 == output2 { - t.Error("expected different output after focusing, but got same output") - } - - // Verify focus state - if !item.IsFocused() { - t.Error("expected item to be focused") - } - - // Blur the item - item.Blur() - - // Render again - should show blur style again - screen3 := uv.NewScreenBuffer(20, 5) - item.Draw(&screen3, area) - output3 := screen3.Render() - - // Output should match original blur output - if output1 != output3 { - t.Error("expected same output after blurring as initial state") - } - - // Verify blur state - if item.IsFocused() { - t.Error("expected item to be blurred") - } -} - -func TestFocusableItemHeightWithBorder(t *testing.T) { - // Create a style with a border (adds 2 to vertical height) - borderStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()) - - // Item without styles has height 1 - plainItem := NewStringItem("Test") - plainHeight := plainItem.Height(20) - if plainHeight != 1 { - t.Errorf("expected plain height 1, got %d", plainHeight) - } - - // Item with border should add border height (2 lines) - item := NewStringItem("Test").WithFocusStyles(&borderStyle, &borderStyle) - itemHeight := item.Height(20) - expectedHeight := 1 + 2 // content + border - if itemHeight != expectedHeight { - t.Errorf("expected height %d (content 1 + border 2), got %d", - expectedHeight, itemHeight) - } -} - -func TestFocusableItemInList(t *testing.T) { - focusStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("86")) - - blurStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("240")) - - // Create list with focusable items - items := []Item{ - NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle), - NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle), - NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle), - } - - l := New(items...) - l.SetSize(80, 20) - l.SetSelected(0) - - // Focus the list - l.Focus() - - // First item should be focused - firstItem := items[0].(*StringItem) - if !firstItem.IsFocused() { - t.Error("expected first item to be focused after focusing list") - } - - // Render to ensure changes are visible - output1 := l.Render() - if !strings.Contains(output1, "Item 1") { - t.Error("expected output to contain first item") - } - - // Select second item - l.SetSelected(1) - - // First item should be blurred, second focused - if firstItem.IsFocused() { - t.Error("expected first item to be blurred after changing selection") - } - - secondItem := items[1].(*StringItem) - if !secondItem.IsFocused() { - t.Error("expected second item to be focused after selection") - } - - // Render again - should show updated focus - output2 := l.Render() - if !strings.Contains(output2, "Item 2") { - t.Error("expected output to contain second item") - } - - // Outputs should be different - if output1 == output2 { - t.Error("expected different output after selection change") - } -} - -func TestFocusableItemWithNilStyles(t *testing.T) { - // Test with nil styles - should render inner item directly - item := NewStringItem("Plain Item").WithFocusStyles(nil, nil) - - // Height should be based on content (no border since styles are nil) - itemHeight := item.Height(20) - if itemHeight != 1 { - t.Errorf("expected height 1 (no border), got %d", itemHeight) - } - - // Draw should work without styles - screen := uv.NewScreenBuffer(20, 5) - area := uv.Rect(0, 0, 20, 5) - item.Draw(&screen, area) - output := screen.Render() - - // Should contain the inner content - if !strings.Contains(output, "Plain Item") { - t.Error("expected output to contain inner item content") - } - - // Focus/blur should still work but not change appearance - item.Focus() - screen2 := uv.NewScreenBuffer(20, 5) - item.Draw(&screen2, area) - output2 := screen2.Render() - - // Output should be identical since no styles - if output != output2 { - t.Error("expected same output with nil styles whether focused or not") - } - - if !item.IsFocused() { - t.Error("expected item to be focused") - } -} - -func TestFocusableItemWithOnlyFocusStyle(t *testing.T) { - // Test with only focus style (blur is nil) - focusStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("86")) - - item := NewStringItem("Test").WithFocusStyles(&focusStyle, nil) - - // When not focused, should use nil blur style (no border) - screen1 := uv.NewScreenBuffer(20, 5) - area := uv.Rect(0, 0, 20, 5) - item.Draw(&screen1, area) - output1 := screen1.Render() - - // Focus the item - item.Focus() - screen2 := uv.NewScreenBuffer(20, 5) - item.Draw(&screen2, area) - output2 := screen2.Render() - - // Outputs should be different (focused has border, blurred doesn't) - if output1 == output2 { - t.Error("expected different output when only focus style is set") - } -} - -func TestFocusableItemLastLineNotEaten(t *testing.T) { - // Create focusable items with borders - focusStyle := lipgloss.NewStyle(). - Padding(1). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("86")) - - blurStyle := lipgloss.NewStyle(). - BorderForeground(lipgloss.Color("240")) - - items := []Item{ - NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle), - Gap, - NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle), - Gap, - NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle), - Gap, - NewStringItem("Item 4").WithFocusStyles(&focusStyle, &blurStyle), - Gap, - NewStringItem("Item 5").WithFocusStyles(&focusStyle, &blurStyle), - } - - // Items with padding(1) and border are 5 lines each - // Viewport of 10 lines fits exactly 2 items - l := New() - l.SetSize(20, 10) - - for _, item := range items { - l.AppendItem(item) - } - - // Focus the list - l.Focus() - - // Select last item - l.SetSelected(len(items) - 1) - - // Scroll to bottom - l.ScrollToBottom() - - output := l.Render() - - t.Logf("Output:\n%s", output) - t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight()) - - // Select previous - will skip gaps and go to Item 4 - l.SelectPrev() - - output = l.Render() - - t.Logf("Output:\n%s", output) - t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight()) - - // Should show items 3 (unfocused), 4 (focused), and part of 5 (unfocused) - if !strings.Contains(output, "Item 3") { - t.Error("expected output to contain 'Item 3'") - } - if !strings.Contains(output, "Item 4") { - t.Error("expected output to contain 'Item 4'") - } - if !strings.Contains(output, "Item 5") { - t.Error("expected output to contain 'Item 5'") - } - - // Count bottom borders - should have 1 (focused item 4) - bottomBorderCount := 0 - for _, line := range strings.Split(output, "\r\n") { - if strings.Contains(line, "╰") || strings.Contains(line, "└") { - bottomBorderCount++ - } - } - - if bottomBorderCount != 1 { - t.Errorf("expected 1 bottom border (focused item 4), got %d", bottomBorderCount) - } -} diff --git a/internal/ui/list/simplelist.go b/internal/ui/list/simplelist.go deleted file mode 100644 index 10cb0912d42a8d25f2ea635ff69ea41653bbdbce..0000000000000000000000000000000000000000 --- a/internal/ui/list/simplelist.go +++ /dev/null @@ -1,972 +0,0 @@ -package list - -import ( - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/exp/ordered" -) - -const maxGapSize = 100 - -var newlineBuffer = strings.Repeat("\n", maxGapSize) - -// SimpleList is a string-based list with virtual scrolling behavior. -// Based on exp/list but simplified for our needs. -type SimpleList struct { - // Viewport dimensions. - width, height int - - // Scroll offset (in lines from top). - offset int - - // Items. - items []Item - itemIDs map[string]int // ID -> index mapping - - // Rendered content (all items stacked). - rendered string - renderedHeight int // Total height of rendered content in lines - lineOffsets []int // Byte offsets for each line (for fast slicing) - - // Rendered item metadata. - renderedItems map[string]renderedItem - - // Selection. - selectedIdx int - focused bool - - // Focus tracking. - prevSelectedIdx int - - // Mouse/highlight state. - mouseDown bool - mouseDownItem int - mouseDownX int - mouseDownY int // viewport-relative Y - mouseDragItem int - mouseDragX int - mouseDragY int // viewport-relative Y - selectionStartLine int - selectionStartCol int - selectionEndLine int - selectionEndCol int - - // Configuration. - gap int // Gap between items in lines -} - -type renderedItem struct { - view string - height int - start int // Start line in rendered content - end int // End line in rendered content -} - -// NewSimpleList creates a new simple list. -func NewSimpleList(items ...Item) *SimpleList { - l := &SimpleList{ - items: items, - itemIDs: make(map[string]int, len(items)), - renderedItems: make(map[string]renderedItem), - selectedIdx: -1, - prevSelectedIdx: -1, - gap: 0, - selectionStartLine: -1, - selectionStartCol: -1, - selectionEndLine: -1, - selectionEndCol: -1, - } - - // Build ID map. - for i, item := range items { - if idItem, ok := item.(interface{ ID() string }); ok { - l.itemIDs[idItem.ID()] = i - } - } - - return l -} - -// Init initializes the list (Bubbletea lifecycle). -func (l *SimpleList) Init() tea.Cmd { - return l.render() -} - -// Update handles messages (Bubbletea lifecycle). -func (l *SimpleList) Update(msg tea.Msg) (*SimpleList, tea.Cmd) { - return l, nil -} - -// View returns the visible viewport (Bubbletea lifecycle). -func (l *SimpleList) View() string { - if l.height <= 0 || l.width <= 0 { - return "" - } - - start, end := l.viewPosition() - viewStart := max(0, start) - viewEnd := end - - if viewStart > viewEnd { - return "" - } - - view := l.getLines(viewStart, viewEnd) - - // Apply width/height constraints. - view = lipgloss.NewStyle(). - Height(l.height). - Width(l.width). - Render(view) - - // Apply highlighting if active. - if l.hasSelection() { - return l.renderSelection(view) - } - - return view -} - -// viewPosition returns the start and end line indices for the viewport. -func (l *SimpleList) viewPosition() (int, int) { - start := max(0, l.offset) - end := min(l.offset+l.height-1, l.renderedHeight-1) - start = min(start, end) - return start, end -} - -// getLines returns lines [start, end] from rendered content. -func (l *SimpleList) 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 // Exclude newline - } else { - endOffset = len(l.rendered) - } - - if startOffset >= len(l.rendered) { - return "" - } - endOffset = min(endOffset, len(l.rendered)) - - return l.rendered[startOffset:endOffset] -} - -// render rebuilds the rendered content from all items. -func (l *SimpleList) render() tea.Cmd { - if l.width <= 0 || l.height <= 0 || len(l.items) == 0 { - return nil - } - - // Set default selection if none. - if l.selectedIdx < 0 && len(l.items) > 0 { - l.selectedIdx = 0 - } - - // Handle focus changes. - var focusCmd tea.Cmd - if l.focused { - focusCmd = l.focusSelectedItem() - } else { - focusCmd = l.blurSelectedItem() - } - - // Render all items. - var b strings.Builder - currentLine := 0 - - for i, item := range l.items { - // Render item. - view := l.renderItem(item) - height := lipgloss.Height(view) - - // Store metadata. - rItem := renderedItem{ - view: view, - height: height, - start: currentLine, - end: currentLine + height - 1, - } - - if idItem, ok := item.(interface{ ID() string }); ok { - l.renderedItems[idItem.ID()] = rItem - } - - // Append to rendered content. - b.WriteString(view) - - // Add gap after item (except last). - gap := l.gap - if i == len(l.items)-1 { - gap = 0 - } - - if gap > 0 { - if gap <= maxGapSize { - b.WriteString(newlineBuffer[:gap]) - } else { - b.WriteString(strings.Repeat("\n", gap)) - } - } - - currentLine += height + gap - } - - l.setRendered(b.String()) - - // Scroll to selected item. - if l.focused && l.selectedIdx >= 0 { - l.scrollToSelection() - } - - return focusCmd -} - -// renderItem renders a single item. -func (l *SimpleList) renderItem(item Item) string { - // Create a buffer for the item. - buf := uv.NewScreenBuffer(l.width, 1000) // Max height - area := uv.Rect(0, 0, l.width, 1000) - item.Draw(&buf, area) - - // Find actual height. - height := l.measureBufferHeight(&buf) - if height == 0 { - height = 1 - } - - // Render to string. - return buf.Render() -} - -// measureBufferHeight finds the actual content height in a buffer. -func (l *SimpleList) measureBufferHeight(buf *uv.ScreenBuffer) int { - height := buf.Height() - - // Scan from bottom up to find last non-empty line. - for y := height - 1; y >= 0; y-- { - line := buf.Line(y) - if l.lineHasContent(line) { - return y + 1 - } - } - - return 0 -} - -// lineHasContent checks if a line has any non-empty cells. -func (l *SimpleList) lineHasContent(line uv.Line) bool { - for x := 0; x < len(line); x++ { - cell := line.At(x) - if cell != nil && !cell.IsZero() && cell.Content != "" && cell.Content != " " { - return true - } - } - return false -} - -// setRendered updates the rendered content and caches line offsets. -func (l *SimpleList) setRendered(rendered string) { - l.rendered = rendered - l.renderedHeight = lipgloss.Height(rendered) - - // Build line offset cache. - 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 - } -} - -// scrollToSelection scrolls to make the selected item visible. -func (l *SimpleList) scrollToSelection() { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return - } - - // Get selected item metadata. - var rItem *renderedItem - if idItem, ok := l.items[l.selectedIdx].(interface{ ID() string }); ok { - if ri, ok := l.renderedItems[idItem.ID()]; ok { - rItem = &ri - } - } - - if rItem == nil { - return - } - - start, end := l.viewPosition() - - // Already visible. - if rItem.start >= start && rItem.end <= end { - return - } - - // Item is above viewport - scroll up. - if rItem.start < start { - l.offset = rItem.start - return - } - - // Item is below viewport - scroll down. - if rItem.end > end { - l.offset = max(0, rItem.end-l.height+1) - } -} - -// Focus/blur management. - -func (l *SimpleList) focusSelectedItem() tea.Cmd { - if l.selectedIdx < 0 || !l.focused { - return nil - } - - var cmds []tea.Cmd - - // Blur previous. - if l.prevSelectedIdx >= 0 && l.prevSelectedIdx != l.selectedIdx && l.prevSelectedIdx < len(l.items) { - if f, ok := l.items[l.prevSelectedIdx].(Focusable); ok && f.IsFocused() { - f.Blur() - } - } - - // Focus current. - if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) { - if f, ok := l.items[l.selectedIdx].(Focusable); ok && !f.IsFocused() { - f.Focus() - } - } - - l.prevSelectedIdx = l.selectedIdx - return tea.Batch(cmds...) -} - -func (l *SimpleList) blurSelectedItem() tea.Cmd { - if l.selectedIdx < 0 || l.focused { - return nil - } - - if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) { - if f, ok := l.items[l.selectedIdx].(Focusable); ok && f.IsFocused() { - f.Blur() - } - } - - return nil -} - -// Public API. - -// SetSize sets the viewport dimensions. -func (l *SimpleList) SetSize(width, height int) tea.Cmd { - oldWidth := l.width - l.width = width - l.height = height - - if oldWidth != width { - // Width changed - need to re-render. - return l.render() - } - - return nil -} - -// Width returns the viewport width. -func (l *SimpleList) Width() int { - return l.width -} - -// Height returns the viewport height. -func (l *SimpleList) Height() int { - return l.height -} - -// GetSize returns the viewport dimensions. -func (l *SimpleList) GetSize() (int, int) { - return l.width, l.height -} - -// Items returns all items. -func (l *SimpleList) Items() []Item { - return l.items -} - -// Len returns the number of items. -func (l *SimpleList) Len() int { - return len(l.items) -} - -// SetItems replaces all items. -func (l *SimpleList) SetItems(items []Item) tea.Cmd { - l.items = items - l.itemIDs = make(map[string]int, len(items)) - l.renderedItems = make(map[string]renderedItem) - l.selectedIdx = -1 - l.prevSelectedIdx = -1 - l.offset = 0 - - // Build ID map. - for i, item := range items { - if idItem, ok := item.(interface{ ID() string }); ok { - l.itemIDs[idItem.ID()] = i - } - } - - return l.render() -} - -// AppendItem adds an item to the end. -func (l *SimpleList) AppendItem(item Item) tea.Cmd { - l.items = append(l.items, item) - - if idItem, ok := item.(interface{ ID() string }); ok { - l.itemIDs[idItem.ID()] = len(l.items) - 1 - } - - return l.render() -} - -// PrependItem adds an item to the beginning. -func (l *SimpleList) PrependItem(item Item) tea.Cmd { - l.items = append([]Item{item}, l.items...) - - // Rebuild ID map (indices shifted). - l.itemIDs = make(map[string]int, len(l.items)) - for i, it := range l.items { - if idItem, ok := it.(interface{ ID() string }); ok { - l.itemIDs[idItem.ID()] = i - } - } - - // Adjust selection. - if l.selectedIdx >= 0 { - l.selectedIdx++ - } - if l.prevSelectedIdx >= 0 { - l.prevSelectedIdx++ - } - - return l.render() -} - -// UpdateItem replaces an item at the given index. -func (l *SimpleList) UpdateItem(idx int, item Item) tea.Cmd { - if idx < 0 || idx >= len(l.items) { - return nil - } - - l.items[idx] = item - - // Update ID map. - if idItem, ok := item.(interface{ ID() string }); ok { - l.itemIDs[idItem.ID()] = idx - } - - return l.render() -} - -// DeleteItem removes an item at the given index. -func (l *SimpleList) DeleteItem(idx int) tea.Cmd { - if idx < 0 || idx >= len(l.items) { - return nil - } - - l.items = append(l.items[:idx], l.items[idx+1:]...) - - // Rebuild ID map (indices shifted). - l.itemIDs = make(map[string]int, len(l.items)) - for i, it := range l.items { - if idItem, ok := it.(interface{ ID() string }); ok { - l.itemIDs[idItem.ID()] = i - } - } - - // Adjust selection. - if l.selectedIdx == idx { - if idx > 0 { - l.selectedIdx = idx - 1 - } else if len(l.items) > 0 { - l.selectedIdx = 0 - } else { - l.selectedIdx = -1 - } - } else if l.selectedIdx > idx { - l.selectedIdx-- - } - - if l.prevSelectedIdx == idx { - l.prevSelectedIdx = -1 - } else if l.prevSelectedIdx > idx { - l.prevSelectedIdx-- - } - - return l.render() -} - -// Focus sets the list as focused. -func (l *SimpleList) Focus() tea.Cmd { - l.focused = true - return l.render() -} - -// Blur removes focus from the list. -func (l *SimpleList) Blur() tea.Cmd { - l.focused = false - return l.render() -} - -// Focused returns whether the list is focused. -func (l *SimpleList) Focused() bool { - return l.focused -} - -// Selection. - -// Selected returns the currently selected item index. -func (l *SimpleList) Selected() int { - return l.selectedIdx -} - -// SelectedIndex returns the currently selected item index. -func (l *SimpleList) SelectedIndex() int { - return l.selectedIdx -} - -// SelectedItem returns the currently selected item. -func (l *SimpleList) SelectedItem() Item { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return nil - } - return l.items[l.selectedIdx] -} - -// SetSelected sets the selected item by index. -func (l *SimpleList) SetSelected(idx int) tea.Cmd { - if idx < -1 || idx >= len(l.items) { - return nil - } - - if l.selectedIdx == idx { - return nil - } - - l.prevSelectedIdx = l.selectedIdx - l.selectedIdx = idx - - return l.render() -} - -// SelectFirst selects the first item. -func (l *SimpleList) SelectFirst() tea.Cmd { - return l.SetSelected(0) -} - -// SelectLast selects the last item. -func (l *SimpleList) SelectLast() tea.Cmd { - if len(l.items) > 0 { - return l.SetSelected(len(l.items) - 1) - } - return nil -} - -// SelectNext selects the next item. -func (l *SimpleList) SelectNext() tea.Cmd { - if l.selectedIdx < len(l.items)-1 { - return l.SetSelected(l.selectedIdx + 1) - } - return nil -} - -// SelectPrev selects the previous item. -func (l *SimpleList) SelectPrev() tea.Cmd { - if l.selectedIdx > 0 { - return l.SetSelected(l.selectedIdx - 1) - } - return nil -} - -// SelectNextWrap selects the next item (wraps to beginning). -func (l *SimpleList) SelectNextWrap() tea.Cmd { - if len(l.items) == 0 { - return nil - } - nextIdx := (l.selectedIdx + 1) % len(l.items) - return l.SetSelected(nextIdx) -} - -// SelectPrevWrap selects the previous item (wraps to end). -func (l *SimpleList) SelectPrevWrap() tea.Cmd { - if len(l.items) == 0 { - return nil - } - prevIdx := (l.selectedIdx - 1 + len(l.items)) % len(l.items) - return l.SetSelected(prevIdx) -} - -// SelectFirstInView selects the first fully visible item. -func (l *SimpleList) SelectFirstInView() tea.Cmd { - if len(l.items) == 0 { - return nil - } - - start, end := l.viewPosition() - - for i := 0; i < len(l.items); i++ { - if idItem, ok := l.items[i].(interface{ ID() string }); ok { - if rItem, ok := l.renderedItems[idItem.ID()]; ok { - // Check if fully visible. - if rItem.start >= start && rItem.end <= end { - return l.SetSelected(i) - } - } - } - } - - return nil -} - -// SelectLastInView selects the last fully visible item. -func (l *SimpleList) SelectLastInView() tea.Cmd { - if len(l.items) == 0 { - return nil - } - - start, end := l.viewPosition() - - for i := len(l.items) - 1; i >= 0; i-- { - if idItem, ok := l.items[i].(interface{ ID() string }); ok { - if rItem, ok := l.renderedItems[idItem.ID()]; ok { - // Check if fully visible. - if rItem.start >= start && rItem.end <= end { - return l.SetSelected(i) - } - } - } - } - - return nil -} - -// SelectedItemInView returns true if the selected item is visible. -func (l *SimpleList) SelectedItemInView() bool { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return false - } - - var rItem *renderedItem - if idItem, ok := l.items[l.selectedIdx].(interface{ ID() string }); ok { - if ri, ok := l.renderedItems[idItem.ID()]; ok { - rItem = &ri - } - } - - if rItem == nil { - return false - } - - start, end := l.viewPosition() - return rItem.start < end && rItem.end > start -} - -// Scrolling. - -// Offset returns the current scroll offset. -func (l *SimpleList) Offset() int { - return l.offset -} - -// TotalHeight returns the total height of all items. -func (l *SimpleList) TotalHeight() int { - return l.renderedHeight -} - -// ScrollBy scrolls by the given number of lines. -func (l *SimpleList) ScrollBy(deltaLines int) tea.Cmd { - l.offset += deltaLines - l.clampOffset() - return nil -} - -// ScrollToTop scrolls to the top. -func (l *SimpleList) ScrollToTop() tea.Cmd { - l.offset = 0 - return nil -} - -// ScrollToBottom scrolls to the bottom. -func (l *SimpleList) ScrollToBottom() tea.Cmd { - l.offset = l.renderedHeight - l.height - l.clampOffset() - return nil -} - -// AtTop returns true if scrolled to the top. -func (l *SimpleList) AtTop() bool { - return l.offset <= 0 -} - -// AtBottom returns true if scrolled to the bottom. -func (l *SimpleList) AtBottom() bool { - return l.offset >= l.renderedHeight-l.height -} - -// ScrollToItem scrolls to make an item visible. -func (l *SimpleList) ScrollToItem(idx int) tea.Cmd { - if idx < 0 || idx >= len(l.items) { - return nil - } - - var rItem *renderedItem - if idItem, ok := l.items[idx].(interface{ ID() string }); ok { - if ri, ok := l.renderedItems[idItem.ID()]; ok { - rItem = &ri - } - } - - if rItem == nil { - return nil - } - - start, end := l.viewPosition() - - // Already visible. - if rItem.start >= start && rItem.end <= end { - return nil - } - - // Above viewport. - if rItem.start < start { - l.offset = rItem.start - return nil - } - - // Below viewport. - if rItem.end > end { - l.offset = rItem.end - l.height + 1 - l.clampOffset() - } - - return nil -} - -// ScrollToSelected scrolls to the selected item. -func (l *SimpleList) ScrollToSelected() tea.Cmd { - if l.selectedIdx >= 0 { - return l.ScrollToItem(l.selectedIdx) - } - return nil -} - -func (l *SimpleList) clampOffset() { - maxOffset := l.renderedHeight - l.height - if maxOffset < 0 { - maxOffset = 0 - } - l.offset = ordered.Clamp(l.offset, 0, maxOffset) -} - -// Mouse and highlighting. - -// HandleMouseDown handles mouse press. -func (l *SimpleList) HandleMouseDown(x, y int) bool { - if x < 0 || y < 0 || x >= l.width || y >= l.height { - return false - } - - // Find item at viewport y. - contentY := l.offset + y - itemIdx := l.findItemAtLine(contentY) - - if itemIdx < 0 { - return false - } - - l.mouseDown = true - l.mouseDownItem = itemIdx - l.mouseDownX = x - l.mouseDownY = y - l.mouseDragItem = itemIdx - l.mouseDragX = x - l.mouseDragY = y - - // Start selection. - l.selectionStartLine = y - l.selectionStartCol = x - l.selectionEndLine = y - l.selectionEndCol = x - - // Select item. - l.SetSelected(itemIdx) - - return true -} - -// HandleMouseDrag handles mouse drag. -func (l *SimpleList) HandleMouseDrag(x, y int) bool { - if !l.mouseDown { - return false - } - - // Clamp coordinates to viewport bounds. - clampedX := max(0, min(x, l.width-1)) - clampedY := max(0, min(y, l.height-1)) - - if clampedY >= 0 && clampedY < l.height { - contentY := l.offset + clampedY - itemIdx := l.findItemAtLine(contentY) - if itemIdx >= 0 { - l.mouseDragItem = itemIdx - l.mouseDragX = clampedX - l.mouseDragY = clampedY - } - } - - // Update selection end (clamped to viewport). - l.selectionEndLine = clampedY - l.selectionEndCol = clampedX - - return true -} - -// HandleMouseUp handles mouse release. -func (l *SimpleList) HandleMouseUp(x, y int) bool { - if !l.mouseDown { - return false - } - - l.mouseDown = false - - // Final selection update (clamped to viewport). - clampedX := max(0, min(x, l.width-1)) - clampedY := max(0, min(y, l.height-1)) - l.selectionEndLine = clampedY - l.selectionEndCol = clampedX - - return true -} - -// ClearHighlight clears the selection. -func (l *SimpleList) ClearHighlight() { - l.selectionStartLine = -1 - l.selectionStartCol = -1 - l.selectionEndLine = -1 - l.selectionEndCol = -1 - l.mouseDown = false - l.mouseDownItem = -1 - l.mouseDragItem = -1 -} - -// GetHighlightedText returns the selected text. -func (l *SimpleList) GetHighlightedText() string { - if !l.hasSelection() { - return "" - } - - return l.renderSelection(l.View()) -} - -func (l *SimpleList) hasSelection() bool { - return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine -} - -// renderSelection applies highlighting to the view and extracts text. -func (l *SimpleList) renderSelection(view string) string { - // Create a screen buffer spanning the viewport. - buf := uv.NewScreenBuffer(l.width, l.height) - area := uv.Rect(0, 0, l.width, l.height) - uv.NewStyledString(view).Draw(&buf, area) - - // Calculate selection bounds. - startLine := min(l.selectionStartLine, l.selectionEndLine) - endLine := max(l.selectionStartLine, l.selectionEndLine) - startCol := l.selectionStartCol - endCol := l.selectionEndCol - - if l.selectionEndLine < l.selectionStartLine { - startCol = l.selectionEndCol - endCol = l.selectionStartCol - } - - // Apply highlighting. - for y := startLine; y <= endLine && y < l.height; y++ { - if y >= buf.Height() { - break - } - - line := buf.Line(y) - - // Determine column range for this line. - colStart := 0 - if y == startLine { - colStart = startCol - } - - colEnd := len(line) - if y == endLine { - colEnd = min(endCol, len(line)) - } - - // Apply highlight style. - for x := colStart; x < colEnd && x < len(line); x++ { - cell := line.At(x) - if cell != nil && !cell.IsZero() { - cell = cell.Clone() - // Toggle reverse for highlight. - if cell.Style.Attrs&uv.AttrReverse != 0 { - cell.Style.Attrs &^= uv.AttrReverse - } else { - cell.Style.Attrs |= uv.AttrReverse - } - buf.SetCell(x, y, cell) - } - } - } - - return buf.Render() -} - -// findItemAtLine finds the item index at the given content line. -func (l *SimpleList) findItemAtLine(line int) int { - for i := 0; i < len(l.items); i++ { - if idItem, ok := l.items[i].(interface{ ID() string }); ok { - if rItem, ok := l.renderedItems[idItem.ID()]; ok { - if line >= rItem.start && line <= rItem.end { - return i - } - } - } - } - return -1 -} - -// Render returns the view (for compatibility). -func (l *SimpleList) Render() string { - return l.View() -} diff --git a/internal/ui/model/items.go b/internal/ui/model/items.go index 09fa1e74a60d2f94eaaf07859edd7383a6eef787..92e7de403eef9ba4dffd7df85872d3c30bddeb4f 100644 --- a/internal/ui/model/items.go +++ b/internal/ui/model/items.go @@ -2,14 +2,11 @@ package model import ( "fmt" - "image" - "log/slog" "path/filepath" "strings" "time" "charm.land/lipgloss/v2" - uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/crush/internal/config" @@ -111,8 +108,6 @@ func (m *MessageContentItem) Render(width int) string { // ToolCallItem represents a rendered tool call with its header and content. type ToolCallItem struct { - BaseFocusable - BaseHighlightable id string toolCall message.ToolCall toolResult message.ToolResult @@ -139,7 +134,6 @@ func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.To maxWidth: 120, sty: sty, } - t.InitHighlight() return t } @@ -156,18 +150,12 @@ func (t *ToolCallItem) ID() string { // FocusStyle returns the focus style. func (t *ToolCallItem) FocusStyle() lipgloss.Style { - if t.focusStyle != nil { - return *t.focusStyle - } - return lipgloss.Style{} + return t.sty.Chat.Message.ToolCallFocused } // BlurStyle returns the blur style. func (t *ToolCallItem) BlurStyle() lipgloss.Style { - if t.blurStyle != nil { - return *t.blurStyle - } - return lipgloss.Style{} + return t.sty.Chat.Message.ToolCallBlurred } // HighlightStyle returns the highlight style. @@ -189,24 +177,10 @@ func (t *ToolCallItem) Render(width int) string { rendered := toolrender.Render(ctx) return rendered - - // return t.RenderWithHighlight(rendered, width, style) -} - -// SetHighlight implements list.Highlightable. -func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) { - t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) -} - -// UpdateResult updates the tool result and invalidates the cache if needed. -func (t *ToolCallItem) UpdateResult(result message.ToolResult) { - t.toolResult = result } // AttachmentItem represents a file attachment in a user message. type AttachmentItem struct { - BaseFocusable - BaseHighlightable id string filename string path string @@ -221,7 +195,6 @@ func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *Attachmen path: path, sty: sty, } - a.InitHighlight() return a } @@ -232,18 +205,12 @@ func (a *AttachmentItem) ID() string { // FocusStyle returns the focus style. func (a *AttachmentItem) FocusStyle() lipgloss.Style { - if a.focusStyle != nil { - return *a.focusStyle - } - return lipgloss.Style{} + return a.sty.Chat.Message.AssistantFocused } // BlurStyle returns the blur style. func (a *AttachmentItem) BlurStyle() lipgloss.Style { - if a.blurStyle != nil { - return *a.blurStyle - } - return lipgloss.Style{} + return a.sty.Chat.Message.AssistantBlurred } // HighlightStyle returns the highlight style. @@ -410,16 +377,6 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s return items } - // Create base styles for the message - var focusStyle, blurStyle lipgloss.Style - if msg.Role == message.User { - focusStyle = sty.Chat.Message.UserFocused - blurStyle = sty.Chat.Message.UserBlurred - } else { - focusStyle = sty.Chat.Message.AssistantFocused - blurStyle = sty.Chat.Message.AssistantBlurred - } - // Process user messages if msg.Role == message.User { // Add main text content @@ -444,8 +401,6 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s attachment.Path, sty, ) - item.SetHighlightStyle(ToStyler(sty.TextSelection)) - item.SetFocusStyles(&focusStyle, &blurStyle) items = append(items, item) } @@ -566,11 +521,6 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s sty, ) - item.SetHighlightStyle(ToStyler(sty.TextSelection)) - - // Tool calls use muted style with optional focus border - item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred) - items = append(items, item) } @@ -596,299 +546,3 @@ func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResu } return resultMap } - -// BaseFocusable provides common focus state and styling for items. -// Embed this type to add focus behavior to any item. -type BaseFocusable struct { - focused bool - focusStyle *lipgloss.Style - blurStyle *lipgloss.Style -} - -// Focus implements Focusable interface. -func (b *BaseFocusable) Focus(width int, content string) string { - if b.focusStyle != nil { - return b.focusStyle.Render(content) - } - return content -} - -// Blur implements Focusable interface. -func (b *BaseFocusable) Blur(width int, content string) string { - if b.blurStyle != nil { - return b.blurStyle.Render(content) - } - return content -} - -// Focus implements Focusable interface. -// func (b *BaseFocusable) Focus() { -// b.focused = true -// } - -// Blur implements Focusable interface. -// func (b *BaseFocusable) Blur() { -// b.focused = false -// } - -// Focused implements Focusable interface. -func (b *BaseFocusable) Focused() bool { - return b.focused -} - -// HasFocusStyles returns true if both focus and blur styles are configured. -func (b *BaseFocusable) HasFocusStyles() bool { - return b.focusStyle != nil && b.blurStyle != nil -} - -// CurrentStyle returns the current style based on focus state. -// Returns nil if no styles are configured, or if the current state's style is nil. -func (b *BaseFocusable) CurrentStyle() *lipgloss.Style { - if b.focused { - return b.focusStyle - } - return b.blurStyle -} - -// SetFocusStyles sets the focus and blur styles. -func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) { - b.focusStyle = focusStyle - b.blurStyle = blurStyle -} - -// CellStyler defines a function that styles a [uv.Style]. -type CellStyler func(uv.Style) uv.Style - -// BaseHighlightable provides common highlight state for items. -// Embed this type to add highlight behavior to any item. -type BaseHighlightable struct { - highlightStartLine int - highlightStartCol int - highlightEndLine int - highlightEndCol int - highlightStyle CellStyler -} - -// SetHighlight implements Highlightable interface. -func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) { - b.highlightStartLine = startLine - b.highlightStartCol = startCol - b.highlightEndLine = endLine - b.highlightEndCol = endCol -} - -// GetHighlight implements Highlightable interface. -func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) { - return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol -} - -// HasHighlight returns true if a highlight region is set. -func (b *BaseHighlightable) HasHighlight() bool { - return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 || - b.highlightEndLine >= 0 || b.highlightEndCol >= 0 -} - -// SetHighlightStyle sets the style function used for highlighting. -func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) { - b.highlightStyle = style -} - -// GetHighlightStyle returns the current highlight style function. -func (b *BaseHighlightable) GetHighlightStyle() CellStyler { - return b.highlightStyle -} - -// InitHighlight initializes the highlight fields with default values. -func (b *BaseHighlightable) InitHighlight() { - b.highlightStartLine = -1 - b.highlightStartCol = -1 - b.highlightEndLine = -1 - b.highlightEndCol = -1 - b.highlightStyle = ToStyler(lipgloss.NewStyle().Reverse(true)) -} - -// Highlight implements Highlightable interface. -func (b *BaseHighlightable) Highlight(width int, content string, startLine, startCol, endLine, endCol int) string { - b.SetHighlight(startLine, startCol, endLine, endCol) - return b.RenderWithHighlight(content, width, nil) -} - -// RenderWithHighlight renders content with optional focus styling and highlighting. -// This is a helper that combines common rendering logic for all items. -// The content parameter should be the raw rendered content before focus styling. -// The style parameter should come from CurrentStyle() and may be nil. -func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string { - // Apply focus/blur styling if configured - rendered := content - if style != nil { - rendered = style.Render(rendered) - } - - if !b.HasHighlight() { - return rendered - } - - height := lipgloss.Height(rendered) - - // Create temp buffer to draw content with highlighting - tempBuf := uv.NewScreenBuffer(width, height) - - // Draw the rendered content to temp buffer - styled := uv.NewStyledString(rendered) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - // Apply highlighting if active - b.ApplyHighlight(&tempBuf, width, height, style) - - return tempBuf.Render() -} - -// ApplyHighlight applies highlighting to a screen buffer. -// This should be called after drawing content to the buffer. -func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) { - if b.highlightStartLine < 0 { - return - } - - var ( - topMargin, topBorder, topPadding int - rightMargin, rightBorder, rightPadding int - bottomMargin, bottomBorder, bottomPadding int - leftMargin, leftBorder, leftPadding int - ) - if style != nil { - topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin() - topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(), - style.GetBorderRightSize(), - style.GetBorderBottomSize(), - style.GetBorderLeftSize() - topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding() - } - - slog.Info("Applying highlight", - "highlightStartLine", b.highlightStartLine, - "highlightStartCol", b.highlightStartCol, - "highlightEndLine", b.highlightEndLine, - "highlightEndCol", b.highlightEndCol, - "width", width, - "height", height, - "margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin), - "borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder), - "paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding), - ) - - // Calculate content area offsets - contentArea := image.Rectangle{ - Min: image.Point{ - X: leftMargin + leftBorder + leftPadding, - Y: topMargin + topBorder + topPadding, - }, - Max: image.Point{ - X: width - (rightMargin + rightBorder + rightPadding), - Y: height - (bottomMargin + bottomBorder + bottomPadding), - }, - } - - for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ { - if y >= buf.Height() { - break - } - - line := buf.Line(y) - - // Determine column range for this line - startCol := 0 - if y == b.highlightStartLine { - startCol = min(b.highlightStartCol, len(line)) - } - - endCol := len(line) - if y == b.highlightEndLine { - endCol = min(b.highlightEndCol, len(line)) - } - - // Track last non-empty position as we go - lastContentX := -1 - - // Single pass: check content and track last non-empty position - for x := startCol; x < endCol; x++ { - cell := line.At(x) - if cell == nil { - continue - } - - // Update last content position if non-empty - if cell.Content != "" && cell.Content != " " { - lastContentX = x - } - } - - // Only apply highlight up to last content position - highlightEnd := endCol - if lastContentX >= 0 { - highlightEnd = lastContentX + 1 - } else if lastContentX == -1 { - highlightEnd = startCol // No content on this line - } - - // Apply highlight style only to cells with content - for x := startCol; x < highlightEnd; x++ { - if !image.Pt(x, y).In(contentArea) { - continue - } - cell := line.At(x) - cell.Style = b.highlightStyle(cell.Style) - } - } -} - -// ToStyler converts a [lipgloss.Style] to a [CellStyler]. -func ToStyler(lgStyle lipgloss.Style) CellStyler { - return func(uv.Style) uv.Style { - return ToStyle(lgStyle) - } -} - -// ToStyle converts an inline [lipgloss.Style] to a [uv.Style]. -func ToStyle(lgStyle lipgloss.Style) uv.Style { - var uvStyle uv.Style - - // Colors are already color.Color - uvStyle.Fg = lgStyle.GetForeground() - uvStyle.Bg = lgStyle.GetBackground() - - // Build attributes using bitwise OR - var attrs uint8 - - if lgStyle.GetBold() { - attrs |= uv.AttrBold - } - - if lgStyle.GetItalic() { - attrs |= uv.AttrItalic - } - - if lgStyle.GetUnderline() { - uvStyle.Underline = uv.UnderlineSingle - } - - if lgStyle.GetStrikethrough() { - attrs |= uv.AttrStrikethrough - } - - if lgStyle.GetFaint() { - attrs |= uv.AttrFaint - } - - if lgStyle.GetBlink() { - attrs |= uv.AttrBlink - } - - if lgStyle.GetReverse() { - attrs |= uv.AttrReverse - } - - uvStyle.Attrs = attrs - - return uvStyle -} From b25730d0c30129f642b0ade59e9804bfceab52ad Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 10 Dec 2025 13:02:05 -0500 Subject: [PATCH 041/335] refactor(ui): rename lazylist package to list and update imports --- internal/ui/lazylist/list.go.bak | 413 -------------------- internal/ui/{lazylist => list}/highlight.go | 2 +- internal/ui/{lazylist => list}/item.go | 2 +- internal/ui/{lazylist => list}/list.go | 2 +- internal/ui/model/chat.go | 12 +- internal/ui/model/items.go | 18 +- 6 files changed, 18 insertions(+), 431 deletions(-) delete mode 100644 internal/ui/lazylist/list.go.bak rename internal/ui/{lazylist => list}/highlight.go (99%) rename internal/ui/{lazylist => list}/item.go (98%) rename internal/ui/{lazylist => list}/list.go (99%) diff --git a/internal/ui/lazylist/list.go.bak b/internal/ui/lazylist/list.go.bak deleted file mode 100644 index 9aec6442e1ace230cc660b8d1cca6bf9b685c845..0000000000000000000000000000000000000000 --- a/internal/ui/lazylist/list.go.bak +++ /dev/null @@ -1,413 +0,0 @@ -package lazylist - -import ( - "log/slog" - "strings" -) - -// List represents a list of items that can be lazily rendered. A list is -// always rendered like a chat conversation where items are stacked vertically -// from top to bottom. -type List struct { - // Viewport size - width, height int - - // Items in the list - items []Item - - // Gap between items (0 or less means no gap) - gap int - - // Focus and selection state - focused bool - selectedIdx int // The current selected index -1 means no selection - - // Item positioning. If a position exists in the map, it means the item has - // been rendered and measured. - itemPositions map[int]itemPosition - - // Rendered content and cache - lines []string - renderedItems map[int]renderedItem - offsetIdx int // Index of the first visible item in the viewport - offsetLine int // The offset line from the start of the offsetIdx item (can be negative) - - // Dirty tracking - dirtyItems map[int]struct{} -} - -// renderedItem holds the rendered content and height of an item. -type renderedItem struct { - content string - height int -} - -// itemPosition holds the start and end line of an item in the list. -type itemPosition struct { - startLine int - endLine int -} - -// Height returns the height of item based on its start and end lines. -func (ip itemPosition) Height() int { - return ip.endLine - ip.startLine -} - -// NewList creates a new lazy-loaded list. -func NewList(items ...Item) *List { - l := new(List) - l.items = items - l.itemPositions = make(map[int]itemPosition) - l.renderedItems = make(map[int]renderedItem) - l.dirtyItems = make(map[int]struct{}) - return l -} - -// SetSize sets the size of the list viewport. -func (l *List) SetSize(width, height int) { - if width != l.width { - // Mark all rendered items as dirty if width changes because their - // layout may change. - for idx := range l.itemPositions { - l.dirtyItems[idx] = struct{}{} - } - } - l.width = width - l.height = height -} - -// SetGap sets the gap between items. -func (l *List) SetGap(gap int) { - l.gap = gap -} - -// Width returns the width of the list viewport. -func (l *List) Width() int { - return l.width -} - -// Height returns the height of the list viewport. -func (l *List) Height() int { - return l.height -} - -// Len returns the number of items in the list. -func (l *List) Len() int { - return len(l.items) -} - -// renderItem renders the item at the given index and updates its cache and -// position. -func (l *List) renderItem(idx int) { - if idx < 0 || idx >= len(l.items) { - return - } - - item := l.items[idx] - rendered := item.Render(l.width) - height := countLines(rendered) - - l.renderedItems[idx] = renderedItem{ - content: rendered, - height: height, - } - - // Calculate item position - var startLine int - if idx == 0 { - startLine = 0 - } else { - prevPos, ok := l.itemPositions[idx-1] - if !ok { - l.renderItem(idx - 1) - prevPos = l.itemPositions[idx-1] - } - startLine = prevPos.endLine - if l.gap > 0 { - startLine += l.gap - } - } - endLine := startLine + height - - l.itemPositions[idx] = itemPosition{ - startLine: startLine, - endLine: endLine, - } -} - -// ScrollToIndex scrolls the list to the given item index. -func (l *List) ScrollToIndex(index int) { - if index < 0 || index >= len(l.items) { - return - } - l.offsetIdx = index - l.offsetLine = 0 -} - -// ScrollBy scrolls the list by the given number of lines. -func (l *List) ScrollBy(lines int) { - l.offsetLine += lines - if l.offsetIdx <= 0 && l.offsetLine < 0 { - l.offsetIdx = 0 - l.offsetLine = 0 - return - } - - // Adjust offset index and line if needed - for l.offsetLine < 0 && l.offsetIdx > 0 { - // Move up to previous item - l.offsetIdx-- - prevPos, ok := l.itemPositions[l.offsetIdx] - if !ok { - l.renderItem(l.offsetIdx) - prevPos = l.itemPositions[l.offsetIdx] - } - l.offsetLine += prevPos.Height() - if l.gap > 0 { - l.offsetLine += l.gap - } - } - - for { - currentPos, ok := l.itemPositions[l.offsetIdx] - if !ok { - l.renderItem(l.offsetIdx) - currentPos = l.itemPositions[l.offsetIdx] - } - if l.offsetLine >= currentPos.Height() { - // Move down to next item - l.offsetLine -= currentPos.Height() - if l.gap > 0 { - l.offsetLine -= l.gap - } - l.offsetIdx++ - if l.offsetIdx >= len(l.items) { - l.offsetIdx = len(l.items) - 1 - l.offsetLine = currentPos.Height() - 1 - break - } - } else { - break - } - } -} - -// findVisibleItems finds the range of items that are visible in the viewport. -func (l *List) findVisibleItems() (startIdx, endIdx int) { - startIdx = l.offsetIdx - endIdx = startIdx + 1 - - // Render items until we fill the viewport - visibleHeight := -l.offsetLine - for endIdx < len(l.items) { - pos, ok := l.itemPositions[endIdx-1] - if !ok { - l.renderItem(endIdx - 1) - pos = l.itemPositions[endIdx-1] - } - visibleHeight += pos.Height() - if endIdx-1 < len(l.items)-1 && l.gap > 0 { - visibleHeight += l.gap - } - if visibleHeight >= l.height { - break - } - endIdx++ - } - - if endIdx > len(l.items)-1 { - endIdx = len(l.items) - 1 - } - - return startIdx, endIdx -} - -// renderLines renders the items between startIdx and endIdx into lines. -func (l *List) renderLines(startIdx, endIdx int) []string { - var lines []string - for idx := startIdx; idx < endIdx+1; idx++ { - rendered, ok := l.renderedItems[idx] - if !ok { - l.renderItem(idx) - rendered = l.renderedItems[idx] - } - itemLines := strings.Split(rendered.content, "\n") - lines = append(lines, itemLines...) - if l.gap > 0 && idx < endIdx { - for i := 0; i < l.gap; i++ { - lines = append(lines, "") - } - } - } - return lines -} - -// Render renders the list and returns the visible lines. -func (l *List) Render() string { - viewStartIdx, viewEndIdx := l.findVisibleItems() - slog.Info("Render", "viewStartIdx", viewStartIdx, "viewEndIdx", viewEndIdx, "offsetIdx", l.offsetIdx, "offsetLine", l.offsetLine) - - for idx := range l.dirtyItems { - if idx >= viewStartIdx && idx <= viewEndIdx { - l.renderItem(idx) - delete(l.dirtyItems, idx) - } - } - - lines := l.renderLines(viewStartIdx, viewEndIdx) - for len(lines) < l.height { - viewStartIdx-- - if viewStartIdx <= 0 { - break - } - - lines = l.renderLines(viewStartIdx, viewEndIdx) - } - - if len(lines) > l.height { - lines = lines[:l.height] - } - - return strings.Join(lines, "\n") -} - -// PrependItems prepends items to the list. -func (l *List) PrependItems(items ...Item) { - l.items = append(items, l.items...) - // Shift existing item positions - newItemPositions := make(map[int]itemPosition) - for idx, pos := range l.itemPositions { - newItemPositions[idx+len(items)] = pos - } - l.itemPositions = newItemPositions - - // Mark all items as dirty - for idx := range l.items { - l.dirtyItems[idx] = struct{}{} - } - - // Adjust offset index - l.offsetIdx += len(items) -} - -// AppendItems appends items to the list. -func (l *List) AppendItems(items ...Item) { - l.items = append(l.items, items...) - for idx := len(l.items) - len(items); idx < len(l.items); idx++ { - l.dirtyItems[idx] = struct{}{} - } -} - -// Focus sets the focus state of the list. -func (l *List) Focus() { - l.focused = true -} - -// Blur removes the focus state from the list. -func (l *List) Blur() { - l.focused = false -} - -// ScrollToTop scrolls the list to the top. -func (l *List) ScrollToTop() { - l.offsetIdx = 0 - l.offsetLine = 0 -} - -// ScrollToBottom scrolls the list to the bottom. -func (l *List) ScrollToBottom() { - l.offsetIdx = len(l.items) - 1 - pos, ok := l.itemPositions[l.offsetIdx] - if !ok { - l.renderItem(l.offsetIdx) - pos = l.itemPositions[l.offsetIdx] - } - l.offsetLine = l.height - pos.Height() -} - -// ScrollToSelected scrolls the list to the selected item. -func (l *List) ScrollToSelected() { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return - } - l.offsetIdx = l.selectedIdx - l.offsetLine = 0 -} - -// SelectedItemInView returns whether the selected item is currently in view. -func (l *List) SelectedItemInView() bool { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return false - } - startIdx, endIdx := l.findVisibleItems() - return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx -} - -// SetSelected sets the selected item index in the list. -func (l *List) SetSelected(index int) { - if index < 0 || index >= len(l.items) { - l.selectedIdx = -1 - } else { - l.selectedIdx = index - } -} - -// SelectPrev selects the previous item in the list. -func (l *List) SelectPrev() { - if l.selectedIdx > 0 { - l.selectedIdx-- - } -} - -// SelectNext selects the next item in the list. -func (l *List) SelectNext() { - if l.selectedIdx < len(l.items)-1 { - l.selectedIdx++ - } -} - -// SelectFirst selects the first item in the list. -func (l *List) SelectFirst() { - if len(l.items) > 0 { - l.selectedIdx = 0 - } -} - -// SelectLast selects the last item in the list. -func (l *List) SelectLast() { - if len(l.items) > 0 { - l.selectedIdx = len(l.items) - 1 - } -} - -// SelectFirstInView selects the first item currently in view. -func (l *List) SelectFirstInView() { - startIdx, _ := l.findVisibleItems() - l.selectedIdx = startIdx -} - -// SelectLastInView selects the last item currently in view. -func (l *List) SelectLastInView() { - _, endIdx := l.findVisibleItems() - l.selectedIdx = endIdx -} - -// HandleMouseDown handles mouse down events at the given line in the viewport. -func (l *List) HandleMouseDown(x, y int) { -} - -// HandleMouseUp handles mouse up events at the given line in the viewport. -func (l *List) HandleMouseUp(x, y int) { -} - -// HandleMouseDrag handles mouse drag events at the given line in the viewport. -func (l *List) HandleMouseDrag(x, y int) { -} - -// countLines counts the number of lines in a string. -func countLines(s string) int { - if s == "" { - return 0 - } - return strings.Count(s, "\n") + 1 -} diff --git a/internal/ui/lazylist/highlight.go b/internal/ui/list/highlight.go similarity index 99% rename from internal/ui/lazylist/highlight.go rename to internal/ui/list/highlight.go index e53d10353dd286fcfe2642db102736c27df34adc..a1454a7edafb3cd623b022eb517593e86b90364e 100644 --- a/internal/ui/lazylist/highlight.go +++ b/internal/ui/list/highlight.go @@ -1,4 +1,4 @@ -package lazylist +package list import ( "image" diff --git a/internal/ui/lazylist/item.go b/internal/ui/list/item.go similarity index 98% rename from internal/ui/lazylist/item.go rename to internal/ui/list/item.go index 2a1b68a9bd666d8bba104274d51e984edc30b76e..48d53b75d057d40f76bf2b16ce2060601c1222f5 100644 --- a/internal/ui/lazylist/item.go +++ b/internal/ui/list/item.go @@ -1,4 +1,4 @@ -package lazylist +package list import ( "charm.land/lipgloss/v2" diff --git a/internal/ui/lazylist/list.go b/internal/ui/list/list.go similarity index 99% rename from internal/ui/lazylist/list.go rename to internal/ui/list/list.go index 319d69a777409c8c10528911aed30b34a83d623e..4414449a746b948031c1c5ed634ee5cf70bd57ab 100644 --- a/internal/ui/lazylist/list.go +++ b/internal/ui/list/list.go @@ -1,4 +1,4 @@ -package lazylist +package list import ( "image" diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index b34b6e43aeb9294bf33a835bbc4c5ba786082e49..4acf84cabf995275287221971fae5775e363bf9f 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -2,7 +2,7 @@ package model import ( "github.com/charmbracelet/crush/internal/ui/common" - "github.com/charmbracelet/crush/internal/ui/lazylist" + "github.com/charmbracelet/crush/internal/ui/list" uv "github.com/charmbracelet/ultraviolet" ) @@ -10,13 +10,13 @@ import ( // messages. type Chat struct { com *common.Common - list *lazylist.List + list *list.List } // NewChat creates a new instance of [Chat] that handles chat interactions and // messages. func NewChat(com *common.Common) *Chat { - l := lazylist.NewList() + l := list.NewList() l.SetGap(1) return &Chat{ com: com, @@ -45,14 +45,14 @@ func (m *Chat) Len() int { } // PrependItems prepends new items to the chat list. -func (m *Chat) PrependItems(items ...lazylist.Item) { +func (m *Chat) PrependItems(items ...list.Item) { m.list.PrependItems(items...) m.list.ScrollToIndex(0) } // AppendMessages appends a new message item to the chat list. func (m *Chat) AppendMessages(msgs ...MessageItem) { - items := make([]lazylist.Item, len(msgs)) + items := make([]list.Item, len(msgs)) for i, msg := range msgs { items[i] = msg } @@ -60,7 +60,7 @@ func (m *Chat) AppendMessages(msgs ...MessageItem) { } // AppendItems appends new items to the chat list. -func (m *Chat) AppendItems(items ...lazylist.Item) { +func (m *Chat) AppendItems(items ...list.Item) { m.list.AppendItems(items...) m.list.ScrollToIndex(m.list.Len() - 1) } diff --git a/internal/ui/model/items.go b/internal/ui/model/items.go index 92e7de403eef9ba4dffd7df85872d3c30bddeb4f..789208df297fa4ab5ddeaa25651a8ac66ef5812a 100644 --- a/internal/ui/model/items.go +++ b/internal/ui/model/items.go @@ -12,7 +12,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/common" - "github.com/charmbracelet/crush/internal/ui/lazylist" + "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/ui/toolrender" ) @@ -23,10 +23,10 @@ type Identifiable interface { } // MessageItem represents a [message.Message] item that can be displayed in the -// UI and be part of a [lazylist.List] identifiable by a unique ID. +// UI and be part of a [list.List] identifiable by a unique ID. type MessageItem interface { - lazylist.Item - lazylist.Item + list.Item + list.Item Identifiable } @@ -81,7 +81,7 @@ func (m *MessageContentItem) HighlightStyle() lipgloss.Style { // Render renders the content at the given width, using cache if available. // -// It implements [lazylist.Item]. +// It implements [list.Item]. func (m *MessageContentItem) Render(width int) string { contentWidth := width // Cap width to maxWidth for markdown @@ -163,7 +163,7 @@ func (t *ToolCallItem) HighlightStyle() lipgloss.Style { return t.sty.TextSelection } -// Render implements lazylist.Item. +// Render implements list.Item. func (t *ToolCallItem) Render(width int) string { // Render the tool call ctx := &toolrender.RenderContext{ @@ -218,7 +218,7 @@ func (a *AttachmentItem) HighlightStyle() lipgloss.Style { return a.sty.TextSelection } -// Render implements lazylist.Item. +// Render implements list.Item. func (a *AttachmentItem) Render(width int) string { const maxFilenameWidth = 10 content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf( @@ -275,7 +275,7 @@ func (t *ThinkingItem) HighlightStyle() lipgloss.Style { return t.sty.TextSelection } -// Render implements lazylist.Item. +// Render implements list.Item. func (t *ThinkingItem) Render(width int) string { cappedWidth := min(width, t.maxWidth) @@ -353,7 +353,7 @@ func (s *SectionHeaderItem) BlurStyle() lipgloss.Style { return s.sty.Chat.Message.AssistantBlurred } -// Render implements lazylist.Item. +// Render implements list.Item. func (s *SectionHeaderItem) Render(width int) string { content := fmt.Sprintf("%s %s %s", s.sty.Subtle.Render(styles.ModelIcon), From bea6010aa9d8f025b0c9f97cbfad12674dacda8a Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 11 Dec 2025 14:22:39 -0500 Subject: [PATCH 042/335] fix(ui): use new lipgloss compositor --- internal/ui/dialog/dialog.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 2d207250c4a3db5cfe9567790e0b818445e6a44c..a61b33c8fc9c0eca88425bdef03499a5c387b378 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -110,13 +110,13 @@ func (d *Overlay) View() string { } // Compose all the dialogs into a single view - canvas := lipgloss.NewCanvas() - for _, dialog := range d.dialogs { - layer := lipgloss.NewLayer(dialog.View()) - canvas.AddLayers(layer) + dialogs := make([]*lipgloss.Layer, len(d.dialogs)) + for i, dialog := range d.dialogs { + dialogs[i] = lipgloss.NewLayer(dialog.View()) } - return canvas.Render() + comp := lipgloss.NewCompositor(dialogs...) + return comp.Render() } // ShortHelp implements [help.KeyMap]. From 318b2c681bb917a21b9126ef4ed2f2d1cad646f7 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 11 Dec 2025 14:22:52 -0500 Subject: [PATCH 043/335] fix(ui): list: prevent negative offset in list rendering --- internal/ui/list/list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 4414449a746b948031c1c5ed634ee5cf70bd57ab..a86dc97d1af0fe6c938980c3a3aa65bf046ebebf 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -333,7 +333,7 @@ func (l *List) Render() string { itemLines := strings.Split(item.content, "\n") itemHeight := len(itemLines) - if currentOffset < itemHeight { + if currentOffset >= 0 && currentOffset < itemHeight { // Add visible content lines lines = append(lines, itemLines[currentOffset:]...) From 18ee5408c46833f1d9328b1e51913e8d2988e97d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 12 Dec 2025 13:10:37 -0500 Subject: [PATCH 044/335] refactor(ui): dialog: use Action pattern and lipgloss layers --- internal/ui/dialog/dialog.go | 85 +++++++++++++++++------------------- internal/ui/dialog/quit.go | 20 ++++----- internal/ui/model/ui.go | 61 +++++++++++++++++--------- 3 files changed, 91 insertions(+), 75 deletions(-) diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index a61b33c8fc9c0eca88425bdef03499a5c387b378..046e73aa6b6466fe179f6358fc23a93774b5ca74 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -4,7 +4,12 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/ui/common" +) + +// CloseKey is the default key binding to close dialogs. +var CloseKey = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "exit"), ) // OverlayKeyMap defines key bindings for dialogs. @@ -12,35 +17,50 @@ type OverlayKeyMap struct { Close key.Binding } -// DefaultOverlayKeyMap returns the default key bindings for dialogs. -func DefaultOverlayKeyMap() OverlayKeyMap { - return OverlayKeyMap{ - Close: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - ), - } +// ActionType represents the type of action taken by a dialog. +type ActionType int + +const ( + // ActionNone indicates no action. + ActionNone ActionType = iota + // ActionClose indicates that the dialog should be closed. + ActionClose +) + +// Action represents an action taken by a dialog. +// It can be used to signal closing or other operations. +type Action struct { + Type ActionType + Payload any } // Dialog is a component that can be displayed on top of the UI. type Dialog interface { - common.Model[Dialog] ID() string + Update(msg tea.Msg) (Action, tea.Cmd) + Layer() *lipgloss.Layer } // Overlay manages multiple dialogs as an overlay. type Overlay struct { dialogs []Dialog - keyMap OverlayKeyMap } // NewOverlay creates a new [Overlay] instance. func NewOverlay(dialogs ...Dialog) *Overlay { return &Overlay{ dialogs: dialogs, - keyMap: DefaultOverlayKeyMap(), } } +// IsFrontDialog checks if the dialog with the specified ID is at the front. +func (d *Overlay) IsFrontDialog(dialogID string) bool { + if len(d.dialogs) == 0 { + return false + } + return d.dialogs[len(d.dialogs)-1].ID() == dialogID +} + // HasDialogs checks if there are any active dialogs. func (d *Overlay) HasDialogs() bool { return len(d.dialogs) > 0 @@ -83,54 +103,31 @@ func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) { dialog := d.dialogs[idx] switch msg := msg.(type) { case tea.KeyPressMsg: - if key.Matches(msg, d.keyMap.Close) { + if key.Matches(msg, CloseKey) { // Close the current dialog d.removeDialog(idx) return d, nil } } - updatedDialog, cmd := dialog.Update(msg) - if updatedDialog == nil { - // Dialog requested to be closed + action, cmd := dialog.Update(msg) + switch action.Type { + case ActionClose: + // Close the current dialog d.removeDialog(idx) return d, cmd } - // Update the dialog in the stack - d.dialogs[idx] = updatedDialog - return d, cmd } -// View implements [Model]. -func (d *Overlay) View() string { - if len(d.dialogs) == 0 { - return "" - } - - // Compose all the dialogs into a single view - dialogs := make([]*lipgloss.Layer, len(d.dialogs)) +// Layers returns the current stack of dialogs as lipgloss layers. +func (d *Overlay) Layers() []*lipgloss.Layer { + layers := make([]*lipgloss.Layer, len(d.dialogs)) for i, dialog := range d.dialogs { - dialogs[i] = lipgloss.NewLayer(dialog.View()) - } - - comp := lipgloss.NewCompositor(dialogs...) - return comp.Render() -} - -// ShortHelp implements [help.KeyMap]. -func (d *Overlay) ShortHelp() []key.Binding { - return []key.Binding{ - d.keyMap.Close, - } -} - -// FullHelp implements [help.KeyMap]. -func (d *Overlay) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {d.keyMap.Close}, + layers[i] = dialog.Layer() } + return layers } // removeDialog removes a dialog from the stack. diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go index 1ec187d36654420a61e367bc51829a44d4c3a14d..77bcedaaa473fec6a7f79339ddba6ac92f594d34 100644 --- a/internal/ui/dialog/quit.go +++ b/internal/ui/dialog/quit.go @@ -73,30 +73,30 @@ func (*Quit) ID() string { } // Update implements [Model]. -func (q *Quit) Update(msg tea.Msg) (Dialog, tea.Cmd) { +func (q *Quit) Update(msg tea.Msg) (Action, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab): q.selectedNo = !q.selectedNo - return q, nil + return Action{}, nil case key.Matches(msg, q.keyMap.EnterSpace): if !q.selectedNo { - return q, tea.Quit + return Action{}, tea.Quit } - return nil, nil + return Action{}, nil case key.Matches(msg, q.keyMap.Yes): - return q, tea.Quit + return Action{}, tea.Quit case key.Matches(msg, q.keyMap.No, q.keyMap.Close): - return nil, nil + return Action{}, nil } } - return q, nil + return Action{}, nil } -// View implements [Model]. -func (q *Quit) View() string { +// Layer implements [Model]. +func (q *Quit) Layer() *lipgloss.Layer { const question = "Are you sure you want to quit?" baseStyle := q.com.Styles.Base buttonOpts := []common.ButtonOpts{ @@ -113,7 +113,7 @@ func (q *Quit) View() string { ), ) - return q.com.Styles.BorderFocus.Render(content) + return lipgloss.NewLayer(q.com.Styles.BorderFocus.Render(content)) } // ShortHelp implements [help.KeyMap]. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 910d41ea75aa8c67f37b356837f6ca9ba912980c..be06824d42e934f158370e8dd5167bb6e30cbe3f 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -335,28 +335,48 @@ func (m *UI) loadSession(sessionID string) tea.Cmd { } func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { - if m.dialog.HasDialogs() { - return m.updateDialogs(msg) + handleQuitKeys := func(msg tea.KeyPressMsg) bool { + switch { + case key.Matches(msg, m.keyMap.Quit): + if !m.dialog.ContainsDialog(dialog.QuitDialogID) { + m.dialog.AddDialog(dialog.NewQuit(m.com)) + return true + } + } + return false } - handleGlobalKeys := func(msg tea.KeyPressMsg) { + handleGlobalKeys := func(msg tea.KeyPressMsg) bool { + if handleQuitKeys(msg) { + return true + } switch { case key.Matches(msg, m.keyMap.Tab): case key.Matches(msg, m.keyMap.Help): m.help.ShowAll = !m.help.ShowAll m.updateLayoutAndSize() - case key.Matches(msg, m.keyMap.Quit): - if !m.dialog.ContainsDialog(dialog.QuitDialogID) { - m.dialog.AddDialog(dialog.NewQuit(m.com)) - return - } + return true case key.Matches(msg, m.keyMap.Commands): // TODO: Implement me case key.Matches(msg, m.keyMap.Models): // TODO: Implement me case key.Matches(msg, m.keyMap.Sessions): // TODO: Implement me + return true } + return false + } + + if m.dialog.HasDialogs() { + // Always handle quit keys first + if handleQuitKeys(msg) { + return cmds + } + + updatedDialog, cmd := m.dialog.Update(msg) + m.dialog = updatedDialog + cmds = append(cmds, cmd) + return cmds } switch m.state { @@ -501,12 +521,19 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { // This needs to come last to overlay on top of everything if m.dialog.HasDialogs() { - if dialogView := m.dialog.View(); dialogView != "" { - dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView) - dialogArea := common.CenterRect(area, dialogWidth, dialogHeight) - dialog := uv.NewStyledString(dialogView) - dialog.Draw(scr, dialogArea) + dialogLayers := m.dialog.Layers() + layers := make([]*lipgloss.Layer, 0) + for _, layer := range dialogLayers { + if layer == nil { + continue + } + layerW, layerH := layer.Width(), layer.Height() + layerArea := common.CenterRect(area, layerW, layerH) + layers = append(layers, layer.X(layerArea.Min.X).Y(layerArea.Min.Y)) } + + comp := lipgloss.NewCompositor(layers...) + comp.Draw(scr, area) } } @@ -650,14 +677,6 @@ func (m *UI) FullHelp() [][]key.Binding { return binds } -// updateDialogs updates the dialog overlay with the given message and returns cmds -func (m *UI) updateDialogs(msg tea.KeyPressMsg) (cmds []tea.Cmd) { - updatedDialog, cmd := m.dialog.Update(msg) - m.dialog = updatedDialog - cmds = append(cmds, cmd) - return cmds -} - // updateFocused updates the focused model (chat or editor) with the given message // and appends any resulting commands to the cmds slice. func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) { From 819c33d24e6753836ddfe137596842316f17bc4f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 12 Dec 2025 15:24:45 -0500 Subject: [PATCH 045/335] feat(ui): new session selector dialog --- go.mod | 2 +- internal/ui/common/elements.go | 14 +++ internal/ui/dialog/dialog.go | 15 +++ internal/ui/dialog/items.go | 156 ++++++++++++++++++++++++++ internal/ui/dialog/sessions.go | 196 +++++++++++++++++++++++++++++++++ internal/ui/list/filterable.go | 118 ++++++++++++++++++++ internal/ui/list/list.go | 27 +++++ internal/ui/model/chat.go | 10 ++ internal/ui/model/ui.go | 39 +++++-- internal/ui/styles/grad.go | 117 ++++++++++++++++++++ internal/ui/styles/styles.go | 46 +++++++- 11 files changed, 728 insertions(+), 12 deletions(-) create mode 100644 internal/ui/dialog/items.go create mode 100644 internal/ui/dialog/sessions.go create mode 100644 internal/ui/list/filterable.go create mode 100644 internal/ui/styles/grad.go diff --git a/go.mod b/go.mod index a5ca5776567e594689af6aacb33815a4b5edfe6b..0b87cc171a5c1ebda3aee445289f7820c2807a21 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/charmbracelet/x/term v0.2.2 github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec + github.com/dustin/go-humanize v1.0.1 github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 @@ -101,7 +102,6 @@ require ( 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/dustin/go-humanize v1.0.1 // 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/internal/ui/common/elements.go b/internal/ui/common/elements.go index 246543078028f5ab616c88c5b1b75103489e1d1a..1475d123a514b75b930b9479ec59f5e5d8a19cf3 100644 --- a/internal/ui/common/elements.go +++ b/internal/ui/common/elements.go @@ -144,3 +144,17 @@ func Section(t *styles.Styles, text string, width int) string { } return text } + +// DialogTitle renders a dialog title with a decorative line filling the +// remaining width. +func DialogTitle(t *styles.Styles, title string, width int) string { + char := "╱" + length := lipgloss.Width(title) + 1 + remainingWidth := width - length + if remainingWidth > 0 { + lines := strings.Repeat(char, remainingWidth) + lines = styles.ApplyForegroundGrad(t, lines, t.Primary, t.Secondary) + title = title + " " + lines + } + return title +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 046e73aa6b6466fe179f6358fc23a93774b5ca74..73c25151cf7edab317c3c52dd064bc1399598bd6 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -25,6 +25,8 @@ const ( ActionNone ActionType = iota // ActionClose indicates that the dialog should be closed. ActionClose + // ActionSelect indicates that an item has been selected. + ActionSelect ) // Action represents an action taken by a dialog. @@ -81,6 +83,16 @@ func (d *Overlay) AddDialog(dialog Dialog) { d.dialogs = append(d.dialogs, dialog) } +// RemoveDialog removes the dialog with the specified ID from the stack. +func (d *Overlay) RemoveDialog(dialogID string) { + for i, dialog := range d.dialogs { + if dialog.ID() == dialogID { + d.removeDialog(i) + return + } + } +} + // BringToFront brings the dialog with the specified ID to the front. func (d *Overlay) BringToFront(dialogID string) { for i, dialog := range d.dialogs { @@ -116,6 +128,9 @@ func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) { // Close the current dialog d.removeDialog(idx) return d, cmd + case ActionSelect: + // Pass the action up (without modifying the dialog stack) + return d, cmd } return d, cmd diff --git a/internal/ui/dialog/items.go b/internal/ui/dialog/items.go new file mode 100644 index 0000000000000000000000000000000000000000..eb0bfc727d3322f0e928d36069b9d483fc130df5 --- /dev/null +++ b/internal/ui/dialog/items.go @@ -0,0 +1,156 @@ +package dialog + +import ( + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" + "github.com/dustin/go-humanize" + "github.com/rivo/uniseg" + "github.com/sahilm/fuzzy" +) + +// ListItem represents a selectable and searchable item in a dialog list. +type ListItem interface { + list.FilterableItem + list.FocusStylable + list.MatchSettable + + // ID returns the unique identifier of the item. + ID() string +} + +// SessionItem wraps a [session.Session] to implement the [ListItem] interface. +type SessionItem struct { + session.Session + t *styles.Styles + m fuzzy.Match +} + +var _ ListItem = &SessionItem{} + +// Filter returns the filterable value of the session. +func (s *SessionItem) Filter() string { + return s.Session.Title +} + +// ID returns the unique identifier of the session. +func (s *SessionItem) ID() string { + return s.Session.ID +} + +// SetMatch sets the fuzzy match for the session item. +func (s *SessionItem) SetMatch(m fuzzy.Match) { + s.m = m +} + +// Render returns the string representation of the session item. +func (s *SessionItem) Render(width int) string { + age := humanize.Time(time.Unix(s.Session.UpdatedAt, 0)) + age = s.t.Subtle.Render(age) + age = " " + age + ageLen := lipgloss.Width(age) + title := s.Session.Title + titleLen := lipgloss.Width(title) + title = ansi.Truncate(title, max(0, width-ageLen), "…") + right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age) + + if matches := len(s.m.MatchedIndexes); matches > 0 { + var lastPos int + parts := make([]string, 0) + // TODO: Use [ansi.Style].Underline true/false to underline only the + // matched parts instead of using [lipgloss.StyleRanges] since it can + // be cheaper with less allocations. + ranges := matchedRanges(s.m.MatchedIndexes) + for _, rng := range ranges { + start, stop := bytePosToVisibleCharPos(title, rng) + if start > lastPos { + parts = append(parts, title[lastPos:start]) + } + // NOTE: We're using [ansi.Style] here instead of [lipgloss.Style] + // because we can control the underline start and stop more + // precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline] + // which only affect the underline attribute without interfering + // with other styles. + parts = append(parts, + ansi.NewStyle().Underline(true).String(), + title[start:stop+1], + ansi.NewStyle().Underline(false).String(), + ) + lastPos = stop + 1 + } + if lastPos < len(title) { + parts = append(parts, title[lastPos:]) + } + return strings.Join(parts, "") + right + } + return title + right +} + +// FocusStyle returns the style to be applied when the item is focused. +func (s *SessionItem) FocusStyle() lipgloss.Style { + return s.t.Dialog.SelectedItem +} + +// BlurStyle returns the style to be applied when the item is blurred. +func (s *SessionItem) BlurStyle() lipgloss.Style { + return s.t.Dialog.NormalItem +} + +// sessionItems takes a slice of [session.Session]s and convert them to a slice +// of [ListItem]s. +func sessionItems(t *styles.Styles, sessions ...session.Session) []list.FilterableItem { + items := make([]list.FilterableItem, len(sessions)) + for i, s := range sessions { + items[i] = &SessionItem{Session: s, t: t} + } + return items +} + +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 +} diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go new file mode 100644 index 0000000000000000000000000000000000000000..c526268cbb3d09509d3e086fcc73e557d272b3dd --- /dev/null +++ b/internal/ui/dialog/sessions.go @@ -0,0 +1,196 @@ +package dialog + +import ( + "strings" + + "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/session" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" +) + +// SessionDialogID is the identifier for the session selector dialog. +const SessionDialogID = "session" + +// Session is a session selector dialog. +type Session struct { + width, height int + com *common.Common + help help.Model + list *list.FilterableList + input textinput.Model + + keyMap struct { + Select key.Binding + Next key.Binding + Previous key.Binding + Close key.Binding + } +} + +var _ Dialog = (*Session)(nil) + +// SessionSelectedMsg is a message sent when a session is selected. +type SessionSelectedMsg struct { + Session session.Session +} + +// NewSessions creates a new Session dialog. +func NewSessions(com *common.Common, sessions ...session.Session) *Session { + s := new(Session) + s.com = com + help := help.New() + help.Styles = com.Styles.DialogHelpStyles() + + s.help = help + s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...) + s.list.Focus() + s.list.SetSelected(0) + + s.input = textinput.New() + s.input.SetVirtualCursor(false) + s.input.Placeholder = "Enter session name" + s.input.SetStyles(com.Styles.TextInput) + s.input.Focus() + + s.keyMap.Select = key.NewBinding( + key.WithKeys("enter", "tab", "ctrl+y"), + key.WithHelp("enter", "choose"), + ) + s.keyMap.Next = key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ) + s.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ) + s.keyMap.Close = CloseKey + return s +} + +// Cursor returns the cursor position relative to the dialog. +func (s *Session) Cursor() *tea.Cursor { + return s.input.Cursor() +} + +// SetSize sets the size of the dialog. +func (s *Session) SetSize(width, height int) { + s.width = width + s.height = height + innerWidth := width - s.com.Styles.Dialog.View.GetHorizontalFrameSize() + s.input.SetWidth(innerWidth - s.com.Styles.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) + s.list.SetSize(innerWidth, height-6) // (1) title + (3) input + (1) padding + (1) help + s.help.SetWidth(width) +} + +// SelectedItem returns the currently selected item. It may be nil if no item +// is selected. +func (s *Session) SelectedItem() list.Item { + return s.list.SelectedItem() +} + +// ID implements Dialog. +func (s *Session) ID() string { + return SessionDialogID +} + +// Update implements Dialog. +func (s *Session) Update(msg tea.Msg) (Action, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, s.keyMap.Previous): + s.list.Focus() + s.list.SelectPrev() + s.list.ScrollToSelected() + case key.Matches(msg, s.keyMap.Next): + s.list.Focus() + s.list.SelectNext() + s.list.ScrollToSelected() + case key.Matches(msg, s.keyMap.Select): + if item := s.list.SelectedItem(); item != nil { + sessionItem := item.(*SessionItem) + return Action{Type: ActionSelect, Payload: sessionItem.Session}, SessionSelectCmd(sessionItem.Session) + } + default: + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + s.list.SetFilter(s.input.Value()) + return Action{}, cmd + } + } + return Action{}, nil +} + +// Layer implements Dialog. +func (s *Session) Layer() *lipgloss.Layer { + titleStyle := s.com.Styles.Dialog.Title + helpStyle := s.com.Styles.Dialog.HelpView + dialogStyle := s.com.Styles.Dialog.View.Width(s.width) + inputStyle := s.com.Styles.Dialog.InputPrompt + helpStyle = helpStyle.Width(s.width - dialogStyle.GetHorizontalFrameSize()) + listContent := s.list.Render() + if nlines := lipgloss.Height(listContent); nlines < s.list.Height() { + // pad the list content to avoid jumping when navigating + listContent += strings.Repeat("\n", max(0, s.list.Height()-nlines)) + } + + content := strings.Join([]string{ + titleStyle.Render( + common.DialogTitle( + s.com.Styles, + "Switch Session", + max(0, s.width- + dialogStyle.GetHorizontalFrameSize()- + titleStyle.GetHorizontalFrameSize()))), + "", + inputStyle.Render(s.input.View()), + "", + listContent, + "", + helpStyle.Render(s.help.View(s)), + }, "\n") + + return lipgloss.NewLayer(dialogStyle.Render(content)) +} + +// ShortHelp implements [help.KeyMap]. +func (s *Session) ShortHelp() []key.Binding { + updown := key.NewBinding( + key.WithKeys("down", "up"), + key.WithHelp("↑↓", "choose"), + ) + return []key.Binding{ + updown, + s.keyMap.Select, + s.keyMap.Close, + } +} + +// FullHelp implements [help.KeyMap]. +func (s *Session) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := []key.Binding{ + s.keyMap.Select, + s.keyMap.Next, + s.keyMap.Previous, + s.keyMap.Close, + } + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// SessionSelectCmd creates a command that sends a SessionSelectMsg. +func SessionSelectCmd(s session.Session) tea.Cmd { + return func() tea.Msg { + return SessionSelectedMsg{Session: s} + } +} diff --git a/internal/ui/list/filterable.go b/internal/ui/list/filterable.go new file mode 100644 index 0000000000000000000000000000000000000000..c45db41da2cc6be8bd61fba57818e5a7d902f5cd --- /dev/null +++ b/internal/ui/list/filterable.go @@ -0,0 +1,118 @@ +package list + +import ( + "github.com/sahilm/fuzzy" +) + +// FilterableItem is an item that can be filtered via a query. +type FilterableItem interface { + Item + // Filter returns the value to be used for filtering. + Filter() string +} + +// MatchSettable is an interface for items that can have their match indexes +// and match score set. +type MatchSettable interface { + SetMatch(fuzzy.Match) +} + +// FilterableList is a list that takes filterable items that can be filtered +// via a settable query. +type FilterableList struct { + *List + items []FilterableItem + query string +} + +// NewFilterableList creates a new filterable list. +func NewFilterableList(items ...FilterableItem) *FilterableList { + f := &FilterableList{ + List: NewList(), + items: items, + } + f.SetItems(items...) + return f +} + +// SetItems sets the list items and updates the filtered items. +func (f *FilterableList) SetItems(items ...FilterableItem) { + f.items = items + fitems := make([]Item, len(items)) + for i, item := range items { + fitems[i] = item + } + f.List.SetItems(fitems...) +} + +// AppendItems appends items to the list and updates the filtered items. +func (f *FilterableList) AppendItems(items ...FilterableItem) { + f.items = append(f.items, items...) + itms := make([]Item, len(f.items)) + for i, item := range f.items { + itms[i] = item + } + f.List.SetItems(itms...) +} + +// PrependItems prepends items to the list and updates the filtered items. +func (f *FilterableList) PrependItems(items ...FilterableItem) { + f.items = append(items, f.items...) + itms := make([]Item, len(f.items)) + for i, item := range f.items { + itms[i] = item + } + f.List.SetItems(itms...) +} + +// SetFilter sets the filter query and updates the list items. +func (f *FilterableList) SetFilter(q string) { + f.query = q +} + +type filterableItems []FilterableItem + +func (f filterableItems) Len() int { + return len(f) +} + +func (f filterableItems) String(i int) string { + return f[i].Filter() +} + +// VisibleItems returns the visible items after filtering. +func (f *FilterableList) VisibleItems() []Item { + if f.query == "" { + items := make([]Item, len(f.items)) + for i, item := range f.items { + if ms, ok := item.(MatchSettable); ok { + ms.SetMatch(fuzzy.Match{}) + item = ms.(FilterableItem) + } + items[i] = item + } + return items + } + + items := filterableItems(f.items) + matches := fuzzy.FindFrom(f.query, items) + matchedItems := []Item{} + resultSize := len(matches) + for i := range resultSize { + match := matches[i] + item := items[match.Index] + if ms, ok := item.(MatchSettable); ok { + ms.SetMatch(match) + item = ms.(FilterableItem) + } + matchedItems = append(matchedItems, item) + } + + return matchedItems +} + +// Render renders the filterable list. +func (f *FilterableList) Render() string { + f.List.SetItems(f.VisibleItems()...) + return f.List.Render() +} diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index a86dc97d1af0fe6c938980c3a3aa65bf046ebebf..92585c208f16d91d5c8c3e9f7d8f5f28c4721f9c 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -162,6 +162,7 @@ func (l *List) renderItem(idx int, process bool) renderedItem { if !ok { item := l.items[idx] rendered := item.Render(l.width - style.GetHorizontalFrameSize()) + rendered = strings.TrimRight(rendered, "\n") height := countLines(rendered) ri = renderedItem{ @@ -387,6 +388,23 @@ func (l *List) PrependItems(items ...Item) { } } +// SetItems sets the items in the list. +func (l *List) SetItems(items ...Item) { + l.setItems(true, items...) +} + +// setItems sets the items in the list. If evict is true, it clears the +// rendered item cache. +func (l *List) setItems(evict bool, items ...Item) { + l.items = items + if evict { + l.renderedItems = make(map[int]renderedItem) + } + l.selectedIdx = min(l.selectedIdx, len(l.items)-1) + l.offsetIdx = min(l.offsetIdx, len(l.items)-1) + l.offsetLine = 0 +} + // AppendItems appends items to the list. func (l *List) AppendItems(items ...Item) { l.items = append(l.items, items...) @@ -514,6 +532,15 @@ func (l *List) SelectLast() { } } +// SelectedItem returns the currently selected item. It may be nil if no item +// is selected. +func (l *List) SelectedItem() Item { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return nil + } + return l.items[l.selectedIdx] +} + // SelectFirstInView selects the first item currently in view. func (l *List) SelectFirstInView() { startIdx, _ := l.findVisibleItems() diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 4acf84cabf995275287221971fae5775e363bf9f..562bd2cc79824c5801b57d11d9570344c3a39317 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -50,6 +50,16 @@ func (m *Chat) PrependItems(items ...list.Item) { m.list.ScrollToIndex(0) } +// SetMessages sets the chat messages to the provided list of message items. +func (m *Chat) SetMessages(msgs ...MessageItem) { + items := make([]list.Item, len(msgs)) + for i, msg := range msgs { + items[i] = msg + } + m.list.SetItems(items...) + m.list.ScrollToBottom() +} + // AppendMessages appends a new message item to the chat list. func (m *Chat) AppendMessages(msgs ...MessageItem) { items := make([]list.Item, len(msgs)) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index be06824d42e934f158370e8dd5167bb6e30cbe3f..9fe351283decaeecfedec63ff88c8ddbc5e98b4a 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -50,6 +50,11 @@ const ( uiChatCompact ) +// sessionsLoadedMsg is a message indicating that sessions have been loaded. +type sessionsLoadedMsg struct { + sessions []session.Session +} + type sessionLoadedMsg struct { sess session.Session } @@ -167,13 +172,6 @@ func (m *UI) Init() tea.Cmd { if m.QueryVersion { cmds = append(cmds, tea.RequestTerminalVersion) } - allSessions, _ := m.com.App.Sessions.List(context.Background()) - if len(allSessions) > 0 { - cmds = append(cmds, func() tea.Msg { - // time.Sleep(2 * time.Second) - return m.loadSession(allSessions[0].ID)() - }) - } return tea.Batch(cmds...) } @@ -190,6 +188,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.sendProgressBar { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } + case sessionsLoadedMsg: + sessions := dialog.NewSessions(m.com, msg.sessions...) + sessions.SetSize(min(120, m.width-8), 30) + m.dialog.AddDialog(sessions) + case dialog.SessionSelectedMsg: + m.dialog.RemoveDialog(dialog.SessionDialogID) + cmds = append(cmds, + m.loadSession(msg.Session.ID), + m.loadSessionFiles(msg.Session.ID), + ) case sessionLoadedMsg: m.state = uiChat m.session = &msg.sess @@ -208,7 +216,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for _, msg := range msgPtrs { items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...) } - m.chat.AppendMessages(items...) + + m.chat.SetMessages(items...) // Notify that session loading is done to scroll to bottom. This is // needed because we need to draw the chat list first before we can @@ -361,7 +370,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { case key.Matches(msg, m.keyMap.Models): // TODO: Implement me case key.Matches(msg, m.keyMap.Sessions): - // TODO: Implement me + if m.dialog.ContainsDialog(dialog.SessionDialogID) { + // Bring to front + m.dialog.BringToFront(dialog.SessionDialogID) + } else { + cmds = append(cmds, m.loadSessionsCmd) + } return true } return false @@ -955,6 +969,13 @@ func (m *UI) renderSidebarLogo(width int) { m.sidebarLogo = renderLogo(m.com.Styles, true, width) } +// loadSessionsCmd loads the list of sessions and returns a command that sends +// a sessionFilesLoadedMsg when done. +func (m *UI) loadSessionsCmd() tea.Msg { + allSessions, _ := m.com.App.Sessions.List(context.TODO()) + return sessionsLoadedMsg{sessions: allSessions} +} + // 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{ diff --git a/internal/ui/styles/grad.go b/internal/ui/styles/grad.go new file mode 100644 index 0000000000000000000000000000000000000000..866a00fa501b48caa2a69f559efd7d45964cec97 --- /dev/null +++ b/internal/ui/styles/grad.go @@ -0,0 +1,117 @@ +package styles + +import ( + "fmt" + "image/color" + "strings" + + "github.com/lucasb-eyer/go-colorful" + "github.com/rivo/uniseg" +) + +// ForegroundGrad returns a slice of strings representing the input string +// rendered with a horizontal gradient foreground from color1 to color2. Each +// string in the returned slice corresponds to a grapheme cluster in the input +// string. If bold is true, the rendered strings will be bolded. +func ForegroundGrad(t *Styles, input string, bold bool, color1, color2 color.Color) []string { + if input == "" { + return []string{""} + } + if len(input) == 1 { + style := t.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.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(t *Styles, input string, color1, color2 color.Color) string { + if input == "" { + return "" + } + var o strings.Builder + clusters := ForegroundGrad(t, 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(t *Styles, input string, color1, color2 color.Color) string { + if input == "" { + return "" + } + var o strings.Builder + clusters := ForegroundGrad(t, 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/ui/styles/styles.go b/internal/ui/styles/styles.go index 97d3b4950f2672481a949a7538c00fa2579c3065..cd9df36c8a20713649bdade5405db8ecc855c221 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -256,6 +256,27 @@ type Styles struct { AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold) AgentPrompt lipgloss.Style // Agent prompt text } + + // Dialog styles + Dialog struct { + Title lipgloss.Style + // View is the main content area style. + View lipgloss.Style + // HelpView is the line that contains the help. + HelpView lipgloss.Style + Help struct { + Ellipsis lipgloss.Style + ShortKey lipgloss.Style + ShortDesc lipgloss.Style + ShortSeparator lipgloss.Style + FullKey lipgloss.Style + FullDesc lipgloss.Style + FullSeparator lipgloss.Style + } + NormalItem lipgloss.Style + SelectedItem lipgloss.Style + InputPrompt lipgloss.Style + } } // ChromaTheme converts the current markdown chroma styles to a chroma @@ -298,6 +319,12 @@ func (s *Styles) ChromaTheme() chroma.StyleEntries { } } +// DialogHelpStyles returns the styles for dialog help. +func (s *Styles) DialogHelpStyles() help.Styles { + return help.Styles(s.Dialog.Help) +} + +// DefaultStyles returns the default styles for the UI. func DefaultStyles() Styles { var ( primary = charmtone.Charple @@ -394,7 +421,7 @@ func DefaultStyles() Styles { }, Cursor: textinput.CursorStyle{ Color: secondary, - Shape: tea.CursorBar, + Shape: tea.CursorBlock, Blink: true, }, } @@ -420,7 +447,7 @@ func DefaultStyles() Styles { }, Cursor: textarea.CursorStyle{ Color: secondary, - Shape: tea.CursorBar, + Shape: tea.CursorBlock, Blink: true, }, } @@ -865,6 +892,21 @@ func DefaultStyles() Styles { // Text selection. s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple) + // Dialog styles + s.Dialog.Title = base.Padding(0, 1).Foreground(primary) + s.Dialog.View = base.Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus) + s.Dialog.HelpView = base.Padding(0, 1).AlignHorizontal(lipgloss.Left) + s.Dialog.Help.ShortKey = base.Foreground(fgMuted) + s.Dialog.Help.ShortDesc = base.Foreground(fgSubtle) + s.Dialog.Help.ShortSeparator = base.Foreground(border) + s.Dialog.Help.Ellipsis = base.Foreground(border) + s.Dialog.Help.FullKey = base.Foreground(fgMuted) + s.Dialog.Help.FullDesc = base.Foreground(fgSubtle) + s.Dialog.Help.FullSeparator = base.Foreground(border) + s.Dialog.NormalItem = base.Padding(0, 1).Foreground(fgBase) + s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase) + s.Dialog.InputPrompt = base.Padding(0, 1) + return s } From 53d38c08edbabad0edd47d38a9b126d271b8301c Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 15 Dec 2025 10:19:56 -0500 Subject: [PATCH 046/335] refactor(ui): simplify dialog model and rendering --- internal/ui/common/markdown.go | 4 +-- internal/ui/dialog/dialog.go | 55 ++++++++++------------------------ internal/ui/dialog/quit.go | 20 ++++++------- internal/ui/dialog/sessions.go | 14 ++++----- internal/ui/model/ui.go | 14 +-------- internal/ui/styles/styles.go | 2 +- 6 files changed, 37 insertions(+), 72 deletions(-) diff --git a/internal/ui/common/markdown.go b/internal/ui/common/markdown.go index 3c90c2dc1582160c919f4fe432e78642a0a2c97d..361cbba2ff8bab34214f95980bd20b98a6ead62a 100644 --- a/internal/ui/common/markdown.go +++ b/internal/ui/common/markdown.go @@ -1,9 +1,9 @@ package common import ( + "charm.land/glamour/v2" + gstyles "charm.land/glamour/v2/styles" "github.com/charmbracelet/crush/internal/ui/styles" - "github.com/charmbracelet/glamour/v2" - gstyles "github.com/charmbracelet/glamour/v2/styles" ) // MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 73c25151cf7edab317c3c52dd064bc1399598bd6..29175922b44015a31182c27e7411c91c47a7f31f 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -4,6 +4,8 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/common" + uv "github.com/charmbracelet/ultraviolet" ) // CloseKey is the default key binding to close dialogs. @@ -12,35 +14,11 @@ var CloseKey = key.NewBinding( key.WithHelp("esc", "exit"), ) -// OverlayKeyMap defines key bindings for dialogs. -type OverlayKeyMap struct { - Close key.Binding -} - -// ActionType represents the type of action taken by a dialog. -type ActionType int - -const ( - // ActionNone indicates no action. - ActionNone ActionType = iota - // ActionClose indicates that the dialog should be closed. - ActionClose - // ActionSelect indicates that an item has been selected. - ActionSelect -) - -// Action represents an action taken by a dialog. -// It can be used to signal closing or other operations. -type Action struct { - Type ActionType - Payload any -} - // Dialog is a component that can be displayed on top of the UI. type Dialog interface { ID() string - Update(msg tea.Msg) (Action, tea.Cmd) - Layer() *lipgloss.Layer + Update(msg tea.Msg) tea.Cmd + View() string } // Overlay manages multiple dialogs as an overlay. @@ -122,27 +100,26 @@ func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) { } } - action, cmd := dialog.Update(msg) - switch action.Type { - case ActionClose: + if cmd := dialog.Update(msg); cmd != nil { // Close the current dialog d.removeDialog(idx) return d, cmd - case ActionSelect: - // Pass the action up (without modifying the dialog stack) - return d, cmd } - return d, cmd + return d, nil } -// Layers returns the current stack of dialogs as lipgloss layers. -func (d *Overlay) Layers() []*lipgloss.Layer { - layers := make([]*lipgloss.Layer, len(d.dialogs)) - for i, dialog := range d.dialogs { - layers[i] = dialog.Layer() +// Draw renders the overlay and its dialogs. +func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) { + for _, dialog := range d.dialogs { + view := dialog.View() + viewWidth := lipgloss.Width(view) + viewHeight := lipgloss.Height(view) + center := common.CenterRect(area, viewWidth, viewHeight) + if area.Overlaps(center) { + uv.NewStyledString(view).Draw(scr, center) + } } - return layers } // removeDialog removes a dialog from the stack. diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go index 77bcedaaa473fec6a7f79339ddba6ac92f594d34..142ac5f3974d88bb0ff8aceb9f7241ddde0f8377 100644 --- a/internal/ui/dialog/quit.go +++ b/internal/ui/dialog/quit.go @@ -73,30 +73,30 @@ func (*Quit) ID() string { } // Update implements [Model]. -func (q *Quit) Update(msg tea.Msg) (Action, tea.Cmd) { +func (q *Quit) Update(msg tea.Msg) tea.Cmd { switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab): q.selectedNo = !q.selectedNo - return Action{}, nil + return nil case key.Matches(msg, q.keyMap.EnterSpace): if !q.selectedNo { - return Action{}, tea.Quit + return tea.Quit } - return Action{}, nil + return nil case key.Matches(msg, q.keyMap.Yes): - return Action{}, tea.Quit + return tea.Quit case key.Matches(msg, q.keyMap.No, q.keyMap.Close): - return Action{}, nil + return nil } } - return Action{}, nil + return nil } -// Layer implements [Model]. -func (q *Quit) Layer() *lipgloss.Layer { +// View implements [Dialog]. +func (q *Quit) View() string { const question = "Are you sure you want to quit?" baseStyle := q.com.Styles.Base buttonOpts := []common.ButtonOpts{ @@ -113,7 +113,7 @@ func (q *Quit) Layer() *lipgloss.Layer { ), ) - return lipgloss.NewLayer(q.com.Styles.BorderFocus.Render(content)) + return q.com.Styles.BorderFocus.Render(content) } // ShortHelp implements [help.KeyMap]. diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index c526268cbb3d09509d3e086fcc73e557d272b3dd..edb0289ca26d1303b8ae14d3f6e9d662b66953b1 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -100,7 +100,7 @@ func (s *Session) ID() string { } // Update implements Dialog. -func (s *Session) Update(msg tea.Msg) (Action, tea.Cmd) { +func (s *Session) Update(msg tea.Msg) tea.Cmd { switch msg := msg.(type) { case tea.KeyPressMsg: switch { @@ -115,20 +115,20 @@ func (s *Session) Update(msg tea.Msg) (Action, tea.Cmd) { case key.Matches(msg, s.keyMap.Select): if item := s.list.SelectedItem(); item != nil { sessionItem := item.(*SessionItem) - return Action{Type: ActionSelect, Payload: sessionItem.Session}, SessionSelectCmd(sessionItem.Session) + return SessionSelectCmd(sessionItem.Session) } default: var cmd tea.Cmd s.input, cmd = s.input.Update(msg) s.list.SetFilter(s.input.Value()) - return Action{}, cmd + return cmd } } - return Action{}, nil + return nil } -// Layer implements Dialog. -func (s *Session) Layer() *lipgloss.Layer { +// View implements [Dialog]. +func (s *Session) View() string { titleStyle := s.com.Styles.Dialog.Title helpStyle := s.com.Styles.Dialog.HelpView dialogStyle := s.com.Styles.Dialog.View.Width(s.width) @@ -156,7 +156,7 @@ func (s *Session) Layer() *lipgloss.Layer { helpStyle.Render(s.help.View(s)), }, "\n") - return lipgloss.NewLayer(dialogStyle.Render(content)) + return dialogStyle.Render(content) } // ShortHelp implements [help.KeyMap]. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 9fe351283decaeecfedec63ff88c8ddbc5e98b4a..99aba0af48844fa7dc1b27b4a4a9f18b5f52b23d 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -535,19 +535,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { // This needs to come last to overlay on top of everything if m.dialog.HasDialogs() { - dialogLayers := m.dialog.Layers() - layers := make([]*lipgloss.Layer, 0) - for _, layer := range dialogLayers { - if layer == nil { - continue - } - layerW, layerH := layer.Width(), layer.Height() - layerArea := common.CenterRect(area, layerW, layerH) - layers = append(layers, layer.X(layerArea.Min.X).Y(layerArea.Min.Y)) - } - - comp := lipgloss.NewCompositor(layers...) - comp.Draw(scr, area) + m.dialog.Draw(scr, area) } } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index cd9df36c8a20713649bdade5405db8ecc855c221..8eb077db163fd3eea80eec5e6a4a625d3c3116d6 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -8,10 +8,10 @@ import ( "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/alecthomas/chroma/v2" "github.com/charmbracelet/crush/internal/tui/exp/diffview" - "github.com/charmbracelet/glamour/v2/ansi" "github.com/charmbracelet/x/exp/charmtone" ) From 5d6dbc865bbda659517373683c1a7972bbff2d7e Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 15 Dec 2025 13:16:29 -0500 Subject: [PATCH 047/335] refactor(list): simplify focus and highlight interfaces --- internal/ui/dialog/items.go | 55 ++++++++++++----- internal/ui/list/item.go | 20 +++---- internal/ui/list/list.go | 116 +++++++----------------------------- 3 files changed, 69 insertions(+), 122 deletions(-) diff --git a/internal/ui/dialog/items.go b/internal/ui/dialog/items.go index eb0bfc727d3322f0e928d36069b9d483fc130df5..3cdc010f9f225d2acbe1cb129c010806a9531987 100644 --- a/internal/ui/dialog/items.go +++ b/internal/ui/dialog/items.go @@ -17,7 +17,7 @@ import ( // ListItem represents a selectable and searchable item in a dialog list. type ListItem interface { list.FilterableItem - list.FocusStylable + list.Focusable list.MatchSettable // ID returns the unique identifier of the item. @@ -27,8 +27,10 @@ type ListItem interface { // SessionItem wraps a [session.Session] to implement the [ListItem] interface. type SessionItem struct { session.Session - t *styles.Styles - m fuzzy.Match + t *styles.Styles + m fuzzy.Match + cache map[int]string + focused bool } var _ ListItem = &SessionItem{} @@ -45,13 +47,34 @@ func (s *SessionItem) ID() string { // SetMatch sets the fuzzy match for the session item. func (s *SessionItem) SetMatch(m fuzzy.Match) { + s.cache = nil s.m = m } // Render returns the string representation of the session item. func (s *SessionItem) Render(width int) string { + if s.cache == nil { + s.cache = make(map[int]string) + } + + cached, ok := s.cache[width] + if ok { + return cached + } + + style := s.t.Dialog.NormalItem + if s.focused { + style = s.t.Dialog.SelectedItem + } + + width -= style.GetHorizontalFrameSize() age := humanize.Time(time.Unix(s.Session.UpdatedAt, 0)) - age = s.t.Subtle.Render(age) + if s.focused { + age = s.t.Base.Render(age) + } else { + age = s.t.Subtle.Render(age) + } + age = " " + age ageLen := lipgloss.Width(age) title := s.Session.Title @@ -59,12 +82,10 @@ func (s *SessionItem) Render(width int) string { title = ansi.Truncate(title, max(0, width-ageLen), "…") right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age) + content := title if matches := len(s.m.MatchedIndexes); matches > 0 { var lastPos int parts := make([]string, 0) - // TODO: Use [ansi.Style].Underline true/false to underline only the - // matched parts instead of using [lipgloss.StyleRanges] since it can - // be cheaper with less allocations. ranges := matchedRanges(s.m.MatchedIndexes) for _, rng := range ranges { start, stop := bytePosToVisibleCharPos(title, rng) @@ -86,19 +107,21 @@ func (s *SessionItem) Render(width int) string { if lastPos < len(title) { parts = append(parts, title[lastPos:]) } - return strings.Join(parts, "") + right + + content = strings.Join(parts, "") } - return title + right -} -// FocusStyle returns the style to be applied when the item is focused. -func (s *SessionItem) FocusStyle() lipgloss.Style { - return s.t.Dialog.SelectedItem + content = style.Render(content + right) + s.cache[width] = content + return content } -// BlurStyle returns the style to be applied when the item is blurred. -func (s *SessionItem) BlurStyle() lipgloss.Style { - return s.t.Dialog.NormalItem +// SetFocused sets the focus state of the session item. +func (s *SessionItem) SetFocused(focused bool) { + if s.focused != focused { + s.cache = nil + } + s.focused = focused } // sessionItems takes a slice of [session.Session]s and convert them to a slice diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go index 48d53b75d057d40f76bf2b16ce2060601c1222f5..a544b85b37dedf889cdc1ecb6ae77388040907f2 100644 --- a/internal/ui/list/item.go +++ b/internal/ui/list/item.go @@ -1,7 +1,6 @@ package list import ( - "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) @@ -12,18 +11,17 @@ type Item interface { Render(width int) string } -// FocusStylable represents an item that can be styled based on focus state. -type FocusStylable interface { - // FocusStyle returns the style to apply when the item is focused. - FocusStyle() lipgloss.Style - // BlurStyle returns the style to apply when the item is unfocused. - BlurStyle() lipgloss.Style +// Focusable represents an item that can be aware of focus state changes. +type Focusable interface { + // SetFocused sets the focus state of the item. + SetFocused(focused bool) } -// HighlightStylable represents an item that can be styled for highlighted regions. -type HighlightStylable interface { - // HighlightStyle returns the style to apply for highlighted regions. - HighlightStyle() lipgloss.Style +// Highlightable represents an item that can highlight a portion of its content. +type Highlightable interface { + // Highlight highlights the content from the given start to end positions. + // Use -1 for no highlight. + Highlight(startLine, startCol, endLine, endCol int) } // MouseClickable represents an item that can handle mouse click events. diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 92585c208f16d91d5c8c3e9f7d8f5f28c4721f9c..b758208fd86e055480c030d3791bc337556bed8b 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -1,10 +1,8 @@ package list import ( - "image" "strings" - "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) @@ -35,9 +33,6 @@ type List struct { mouseDragY int // Current Y in item lastHighlighted map[int]bool // Track which items were highlighted in last update - // Rendered content and cache - renderedItems map[int]renderedItem - // offsetIdx is the index of the first visible item in the viewport. offsetIdx int // offsetLine is the number of lines of the item at offsetIdx that are @@ -56,7 +51,6 @@ type renderedItem struct { func NewList(items ...Item) *List { l := new(List) l.items = items - l.renderedItems = make(map[int]renderedItem) l.selectedIdx = -1 l.mouseDownItem = -1 l.mouseDragItem = -1 @@ -66,9 +60,6 @@ func NewList(items ...Item) *List { // SetSize sets the size of the list viewport. func (l *List) SetSize(width, height int) { - if width != l.width { - l.renderedItems = make(map[int]renderedItem) - } l.width = width l.height = height // l.normalizeOffsets() @@ -96,16 +87,16 @@ func (l *List) Len() int { // getItem renders (if needed) and returns the item at the given index. func (l *List) getItem(idx int) renderedItem { - return l.renderItem(idx, false) -} + if idx < 0 || idx >= len(l.items) { + return renderedItem{} + } -// applyHighlight applies highlighting to the given rendered item. -func (l *List) applyHighlight(idx int, ri *renderedItem) { - // Apply highlight if item supports it - if highlightable, ok := l.items[idx].(HighlightStylable); ok { + item := l.items[idx] + if hi, ok := item.(Highlightable); ok { + // Apply highlight startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange() + sLine, sCol, eLine, eCol := -1, -1, -1, -1 if idx >= startItemIdx && idx <= endItemIdx { - var sLine, sCol, eLine, eCol int if idx == startItemIdx && idx == endItemIdx { // Single item selection sLine = startLine @@ -116,8 +107,8 @@ func (l *List) applyHighlight(idx int, ri *renderedItem) { // First item - from start position to end of item sLine = startLine sCol = startCol - eLine = ri.height - 1 - eCol = 9999 // 9999 = end of line + eLine = -1 + eCol = -1 } else if idx == endItemIdx { // Last item - from start of item to end position sLine = 0 @@ -128,82 +119,29 @@ func (l *List) applyHighlight(idx int, ri *renderedItem) { // Middle item - fully highlighted sLine = 0 sCol = 0 - eLine = ri.height - 1 - eCol = 9999 + eLine = -1 + eCol = -1 } - - // Apply offset for styling frame - contentArea := image.Rect(0, 0, l.width, ri.height) - - hiStyle := highlightable.HighlightStyle() - rendered := Highlight(ri.content, contentArea, sLine, sCol, eLine, eCol, ToHighlighter(hiStyle)) - ri.content = rendered } - } -} -// renderItem renders (if needed) and returns the item at the given index. If -// process is true, it applies focus and highlight styling. -func (l *List) renderItem(idx int, process bool) renderedItem { - if idx < 0 || idx >= len(l.items) { - return renderedItem{} + hi.Highlight(sLine, sCol, eLine, eCol) } - var style lipgloss.Style - focusable, isFocusable := l.items[idx].(FocusStylable) - if isFocusable { - style = focusable.BlurStyle() - if l.focused && idx == l.selectedIdx { - style = focusable.FocusStyle() - } + if focusable, isFocusable := item.(Focusable); isFocusable { + focusable.SetFocused(l.focused && idx == l.selectedIdx) } - ri, ok := l.renderedItems[idx] - if !ok { - item := l.items[idx] - rendered := item.Render(l.width - style.GetHorizontalFrameSize()) - rendered = strings.TrimRight(rendered, "\n") - height := countLines(rendered) - - ri = renderedItem{ - content: rendered, - height: height, - } - - l.renderedItems[idx] = ri - } - - if !process { - // Simply return cached rendered item with frame size applied - if vfs := style.GetVerticalFrameSize(); vfs > 0 { - ri.height += vfs - } - return ri - } - - // We apply highlighting before focus styling so that focus styling - // overrides highlight styles. - if l.mouseDownItem >= 0 { - l.applyHighlight(idx, &ri) - } - - if isFocusable { - // Apply focus/blur styling if needed - rendered := style.Render(ri.content) - height := countLines(rendered) - ri.content = rendered - ri.height = height + rendered := item.Render(l.width) + rendered = strings.TrimRight(rendered, "\n") + height := countLines(rendered) + ri := renderedItem{ + content: rendered, + height: height, } return ri } -// invalidateItem invalidates the cached rendered content of the item at the -// given index. -func (l *List) invalidateItem(idx int) { - delete(l.renderedItems, idx) -} - // ScrollToIndex scrolls the list to the given item index. func (l *List) ScrollToIndex(index int) { if index < 0 { @@ -330,7 +268,7 @@ func (l *List) Render() string { linesNeeded := l.height for linesNeeded > 0 && currentIdx < len(l.items) { - item := l.renderItem(currentIdx, true) + item := l.getItem(currentIdx) itemLines := strings.Split(item.content, "\n") itemHeight := len(itemLines) @@ -372,13 +310,6 @@ func (l *List) Render() string { func (l *List) PrependItems(items ...Item) { l.items = append(items, l.items...) - // Shift cache - newCache := make(map[int]renderedItem) - for idx, val := range l.renderedItems { - newCache[idx+len(items)] = val - } - l.renderedItems = newCache - // Keep view position relative to the content that was visible l.offsetIdx += len(items) @@ -397,9 +328,6 @@ func (l *List) SetItems(items ...Item) { // rendered item cache. func (l *List) setItems(evict bool, items ...Item) { l.items = items - if evict { - l.renderedItems = make(map[int]renderedItem) - } l.selectedIdx = min(l.selectedIdx, len(l.items)-1) l.offsetIdx = min(l.offsetIdx, len(l.items)-1) l.offsetLine = 0 @@ -580,8 +508,6 @@ func (l *List) HandleMouseDown(x, y int) bool { if clickable, ok := l.items[itemIdx].(MouseClickable); ok { clickable.HandleMouseClick(ansi.MouseButton1, x, itemY) - l.items[itemIdx] = clickable.(Item) - l.invalidateItem(itemIdx) } return true From 2dc5d0b02728a411df96c38f41612e01f70eeaf6 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 15 Dec 2025 14:00:10 -0500 Subject: [PATCH 048/335] refactor(ui): list: remove mouse highlighting state, add render callbacks The caller can now manage mouse state and apply highlighting via registered render callbacks. --- internal/ui/list/list.go | 176 +++++--------------------------------- internal/ui/model/chat.go | 164 ++++++++++++++++++++++++++++++++--- 2 files changed, 177 insertions(+), 163 deletions(-) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index b758208fd86e055480c030d3791bc337556bed8b..07414882400795eaa1e08fbab19c22d37d98ffa5 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -2,8 +2,6 @@ package list import ( "strings" - - "github.com/charmbracelet/x/ansi" ) // List represents a list of items that can be lazily rendered. A list is @@ -23,22 +21,15 @@ type List struct { focused bool selectedIdx int // The current selected index -1 means no selection - // Mouse state - mouseDown bool - mouseDownItem int // Item index where mouse was pressed - mouseDownX int // X position in item content (character offset) - mouseDownY int // Y position in item (line offset) - mouseDragItem int // Current item index being dragged over - mouseDragX int // Current X in item content - mouseDragY int // Current Y in item - lastHighlighted map[int]bool // Track which items were highlighted in last update - // offsetIdx is the index of the first visible item in the viewport. offsetIdx int // offsetLine is the number of lines of the item at offsetIdx that are // scrolled out of view (above the viewport). // It must always be >= 0. offsetLine int + + // renderCallbacks is a list of callbacks to apply when rendering items. + renderCallbacks []func(idx, selectedIdx int, item Item) Item } // renderedItem holds the rendered content and height of an item. @@ -52,17 +43,19 @@ func NewList(items ...Item) *List { l := new(List) l.items = items l.selectedIdx = -1 - l.mouseDownItem = -1 - l.mouseDragItem = -1 - l.lastHighlighted = make(map[int]bool) return l } +// RegisterRenderCallback registers a callback to be called when rendering +// items. This can be used to modify items before they are rendered. +func (l *List) RegisterRenderCallback(cb func(idx, selectedIdx int, item Item) Item) { + l.renderCallbacks = append(l.renderCallbacks, cb) +} + // SetSize sets the size of the list viewport. func (l *List) SetSize(width, height int) { l.width = width l.height = height - // l.normalizeOffsets() } // SetGap sets the gap between items. @@ -92,39 +85,12 @@ func (l *List) getItem(idx int) renderedItem { } item := l.items[idx] - if hi, ok := item.(Highlightable); ok { - // Apply highlight - startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange() - sLine, sCol, eLine, eCol := -1, -1, -1, -1 - if idx >= startItemIdx && idx <= endItemIdx { - if idx == startItemIdx && idx == endItemIdx { - // Single item selection - sLine = startLine - sCol = startCol - eLine = endLine - eCol = endCol - } else if idx == startItemIdx { - // First item - from start position to end of item - sLine = startLine - sCol = startCol - eLine = -1 - eCol = -1 - } else if idx == endItemIdx { - // Last item - from start of item to end position - sLine = 0 - sCol = 0 - eLine = endLine - eCol = endCol - } else { - // Middle item - fully highlighted - sLine = 0 - sCol = 0 - eLine = -1 - eCol = -1 + if len(l.renderCallbacks) > 0 { + for _, cb := range l.renderCallbacks { + if it := cb(idx, l.selectedIdx, item); it != nil { + item = it } } - - hi.Highlight(sLine, sCol, eLine, eCol) } if focusable, isFocusable := item.(Focusable); isFocusable { @@ -481,80 +447,19 @@ func (l *List) SelectLastInView() { l.selectedIdx = endIdx } -// HandleMouseDown handles mouse down events at the given line in the viewport. -// x and y are viewport-relative coordinates (0,0 = top-left of visible area). -// Returns true if the event was handled. -func (l *List) HandleMouseDown(x, y int) bool { - if len(l.items) == 0 { - return false - } - - // Find which item was clicked - itemIdx, itemY := l.findItemAtY(x, y) - if itemIdx < 0 { - return false - } - - l.mouseDown = true - l.mouseDownItem = itemIdx - l.mouseDownX = x - l.mouseDownY = itemY - l.mouseDragItem = itemIdx - l.mouseDragX = x - l.mouseDragY = itemY - - // Select the clicked item - l.SetSelected(itemIdx) - - if clickable, ok := l.items[itemIdx].(MouseClickable); ok { - clickable.HandleMouseClick(ansi.MouseButton1, x, itemY) - } - - return true -} - -// HandleMouseUp handles mouse up events at the given line in the viewport. -// Returns true if the event was handled. -func (l *List) HandleMouseUp(x, y int) bool { - if !l.mouseDown { - return false - } - - l.mouseDown = false - - return true -} - -// HandleMouseDrag handles mouse drag events at the given line in the viewport. -// x and y are viewport-relative coordinates. -// Returns true if the event was handled. -func (l *List) HandleMouseDrag(x, y int) bool { - if !l.mouseDown { - return false - } - - if len(l.items) == 0 { - return false - } - - // Find which item we're dragging over - itemIdx, itemY := l.findItemAtY(x, y) - if itemIdx < 0 { - return false +// ItemAt returns the item at the given index. +func (l *List) ItemAt(index int) Item { + if index < 0 || index >= len(l.items) { + return nil } - - l.mouseDragItem = itemIdx - l.mouseDragX = x - l.mouseDragY = itemY - - return true + return l.items[index] } -// ClearHighlight clears any active text highlighting. -func (l *List) ClearHighlight() { - l.mouseDownItem = -1 - l.mouseDragItem = -1 - l.lastHighlighted = make(map[int]bool) +// ItemIndexAtPosition returns the item at the given viewport-relative y +// coordinate. Returns the item index and the y offset within that item. It +// returns -1, -1 if no item is found. +func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) { + return l.findItemAtY(x, y) } // findItemAtY finds the item at the given viewport y coordinate. @@ -591,41 +496,6 @@ func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) { return -1, -1 } -// getHighlightRange returns the current highlight range. -func (l *List) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) { - if l.mouseDownItem < 0 { - return -1, -1, -1, -1, -1, -1 - } - - downItemIdx := l.mouseDownItem - dragItemIdx := l.mouseDragItem - - // Determine selection direction - draggingDown := dragItemIdx > downItemIdx || - (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) || - (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX) - - if draggingDown { - // Normal forward selection - startItemIdx = downItemIdx - startLine = l.mouseDownY - startCol = l.mouseDownX - endItemIdx = dragItemIdx - endLine = l.mouseDragY - endCol = l.mouseDragX - } else { - // Backward selection (dragging up) - startItemIdx = dragItemIdx - startLine = l.mouseDragY - startCol = l.mouseDragX - endItemIdx = downItemIdx - endLine = l.mouseDownY - endCol = l.mouseDownX - } - - return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol -} - // countLines counts the number of lines in a string. func countLines(s string) int { if s == "" { diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 562bd2cc79824c5801b57d11d9570344c3a39317..b540b5b6e62d452d4b966b6dc251a182cd543d90 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -4,6 +4,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" ) // Chat represents the chat UI model that handles chat interactions and @@ -11,17 +12,28 @@ import ( type Chat struct { com *common.Common list *list.List + + // Mouse state + mouseDown bool + mouseDownItem int // Item index where mouse was pressed + mouseDownX int // X position in item content (character offset) + mouseDownY int // Y position in item (line offset) + mouseDragItem int // Current item index being dragged over + mouseDragX int // Current X in item content + mouseDragY int // Current Y in item } // NewChat creates a new instance of [Chat] that handles chat interactions and // messages. func NewChat(com *common.Common) *Chat { + c := &Chat{com: com} l := list.NewList() l.SetGap(1) - return &Chat{ - com: com, - list: l, - } + l.RegisterRenderCallback(c.applyHighlightRange) + c.list = l + c.mouseDownItem = -1 + c.mouseDragItem = -1 + return c } // Height returns the height of the chat view port. @@ -146,16 +158,148 @@ func (m *Chat) SelectLastInView() { } // HandleMouseDown handles mouse down events for the chat component. -func (m *Chat) HandleMouseDown(x, y int) { - m.list.HandleMouseDown(x, y) +func (m *Chat) HandleMouseDown(x, y int) bool { + if m.list.Len() == 0 { + return false + } + + itemIdx, itemY := m.list.ItemIndexAtPosition(x, y) + if itemIdx < 0 { + return false + } + + m.mouseDown = true + m.mouseDownItem = itemIdx + m.mouseDownX = x + m.mouseDownY = itemY + m.mouseDragItem = itemIdx + m.mouseDragX = x + m.mouseDragY = itemY + + // Select the item that was clicked + m.list.SetSelected(itemIdx) + + if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok { + return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY) + } + + return true } // HandleMouseUp handles mouse up events for the chat component. -func (m *Chat) HandleMouseUp(x, y int) { - m.list.HandleMouseUp(x, y) +func (m *Chat) HandleMouseUp(x, y int) bool { + if !m.mouseDown { + return false + } + + // TODO: Handle the behavior when mouse is released after a drag selection + // (e.g., copy selected text to clipboard) + + m.mouseDown = false + return true } // HandleMouseDrag handles mouse drag events for the chat component. -func (m *Chat) HandleMouseDrag(x, y int) { - m.list.HandleMouseDrag(x, y) +func (m *Chat) HandleMouseDrag(x, y int) bool { + if !m.mouseDown { + return false + } + + if m.list.Len() == 0 { + return false + } + + itemIdx, itemY := m.list.ItemIndexAtPosition(x, y) + if itemIdx < 0 { + return false + } + + m.mouseDragItem = itemIdx + m.mouseDragX = x + m.mouseDragY = itemY + + return true +} + +// ClearMouse clears the current mouse interaction state. +func (m *Chat) ClearMouse() { + m.mouseDown = false + m.mouseDownItem = -1 + m.mouseDragItem = -1 +} + +// applyHighlightRange applies the current highlight range to the chat items. +func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item { + if hi, ok := item.(list.Highlightable); ok { + // Apply highlight + startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange() + sLine, sCol, eLine, eCol := -1, -1, -1, -1 + if idx >= startItemIdx && idx <= endItemIdx { + if idx == startItemIdx && idx == endItemIdx { + // Single item selection + sLine = startLine + sCol = startCol + eLine = endLine + eCol = endCol + } else if idx == startItemIdx { + // First item - from start position to end of item + sLine = startLine + sCol = startCol + eLine = -1 + eCol = -1 + } else if idx == endItemIdx { + // Last item - from start of item to end position + sLine = 0 + sCol = 0 + eLine = endLine + eCol = endCol + } else { + // Middle item - fully highlighted + sLine = 0 + sCol = 0 + eLine = -1 + eCol = -1 + } + } + + hi.Highlight(sLine, sCol, eLine, eCol) + return hi.(list.Item) + } + + return item +} + +// getHighlightRange returns the current highlight range. +func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) { + if m.mouseDownItem < 0 { + return -1, -1, -1, -1, -1, -1 + } + + downItemIdx := m.mouseDownItem + dragItemIdx := m.mouseDragItem + + // Determine selection direction + draggingDown := dragItemIdx > downItemIdx || + (dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) || + (dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX) + + if draggingDown { + // Normal forward selection + startItemIdx = downItemIdx + startLine = m.mouseDownY + startCol = m.mouseDownX + endItemIdx = dragItemIdx + endLine = m.mouseDragY + endCol = m.mouseDragX + } else { + // Backward selection (dragging up) + startItemIdx = dragItemIdx + startLine = m.mouseDragY + startCol = m.mouseDragX + endItemIdx = downItemIdx + endLine = m.mouseDownY + endCol = m.mouseDownX + } + + return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol } From 039f7443e21fb5e4d602a9f8366d7a11264a5725 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 15 Dec 2025 15:48:44 -0500 Subject: [PATCH 049/335] fix(ui): simplify QuitDialogKeyMap by embedding key bindings directly --- internal/ui/dialog/quit.go | 71 ++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 42 deletions(-) diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go index 142ac5f3974d88bb0ff8aceb9f7241ddde0f8377..b4b5922b57ecb0398a0939c5af27b9d3030cb0d1 100644 --- a/internal/ui/dialog/quit.go +++ b/internal/ui/dialog/quit.go @@ -10,60 +10,47 @@ import ( // QuitDialogID is the identifier for the quit dialog. const QuitDialogID = "quit" -// QuitDialogKeyMap represents key bindings for the quit dialog. -type QuitDialogKeyMap struct { - LeftRight, - EnterSpace, - Yes, - No, - Tab, - Close key.Binding -} - -// DefaultQuitKeyMap returns the default key bindings for the quit dialog. -func DefaultQuitKeyMap() QuitDialogKeyMap { - return QuitDialogKeyMap{ - 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"), - ), - } -} - // Quit represents a confirmation dialog for quitting the application. type Quit struct { com *common.Common - keyMap QuitDialogKeyMap selectedNo bool // true if "No" button is selected + keyMap struct { + LeftRight, + EnterSpace, + Yes, + No, + Tab, + Close key.Binding + } } // NewQuit creates a new quit confirmation dialog. func NewQuit(com *common.Common) *Quit { q := &Quit{ com: com, - keyMap: DefaultQuitKeyMap(), selectedNo: true, } + q.keyMap.LeftRight = key.NewBinding( + key.WithKeys("left", "right"), + key.WithHelp("←/→", "switch options"), + ) + q.keyMap.EnterSpace = key.NewBinding( + key.WithKeys("enter", " "), + key.WithHelp("enter/space", "confirm"), + ) + q.keyMap.Yes = key.NewBinding( + key.WithKeys("y", "Y", "ctrl+c"), + key.WithHelp("y/Y/ctrl+c", "yes"), + ) + q.keyMap.No = key.NewBinding( + key.WithKeys("n", "N"), + key.WithHelp("n/N", "no"), + ) + q.keyMap.Tab = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch options"), + ) + q.keyMap.Close = CloseKey return q } From ed7ef782eae8e202e606c09e1aecf9635dea9eae Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 15 Dec 2025 15:50:13 -0500 Subject: [PATCH 050/335] chore(ui): standardize dialog identifiers --- internal/ui/dialog/quit.go | 6 +++--- internal/ui/dialog/sessions.go | 6 +++--- internal/ui/model/ui.go | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go index b4b5922b57ecb0398a0939c5af27b9d3030cb0d1..4891d62d1f7c9933f2e231ba50c42aa71ca0f2c0 100644 --- a/internal/ui/dialog/quit.go +++ b/internal/ui/dialog/quit.go @@ -7,8 +7,8 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" ) -// QuitDialogID is the identifier for the quit dialog. -const QuitDialogID = "quit" +// QuitID is the identifier for the quit dialog. +const QuitID = "quit" // Quit represents a confirmation dialog for quitting the application. type Quit struct { @@ -56,7 +56,7 @@ func NewQuit(com *common.Common) *Quit { // ID implements [Model]. func (*Quit) ID() string { - return QuitDialogID + return QuitID } // Update implements [Model]. diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index edb0289ca26d1303b8ae14d3f6e9d662b66953b1..ceeeda86dd306f7819ae0b8bc8f7107c6c9c00a0 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -13,8 +13,8 @@ import ( "github.com/charmbracelet/crush/internal/ui/list" ) -// SessionDialogID is the identifier for the session selector dialog. -const SessionDialogID = "session" +// SessionsID is the identifier for the session selector dialog. +const SessionsID = "session" // Session is a session selector dialog. type Session struct { @@ -96,7 +96,7 @@ func (s *Session) SelectedItem() list.Item { // ID implements Dialog. func (s *Session) ID() string { - return SessionDialogID + return SessionsID } // Update implements Dialog. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 99aba0af48844fa7dc1b27b4a4a9f18b5f52b23d..24923955acf057458a64ca48083385926d1ad5a4 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -193,7 +193,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { sessions.SetSize(min(120, m.width-8), 30) m.dialog.AddDialog(sessions) case dialog.SessionSelectedMsg: - m.dialog.RemoveDialog(dialog.SessionDialogID) + m.dialog.RemoveDialog(dialog.SessionsID) cmds = append(cmds, m.loadSession(msg.Session.ID), m.loadSessionFiles(msg.Session.ID), @@ -347,7 +347,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { handleQuitKeys := func(msg tea.KeyPressMsg) bool { switch { case key.Matches(msg, m.keyMap.Quit): - if !m.dialog.ContainsDialog(dialog.QuitDialogID) { + if !m.dialog.ContainsDialog(dialog.QuitID) { m.dialog.AddDialog(dialog.NewQuit(m.com)) return true } @@ -370,9 +370,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { case key.Matches(msg, m.keyMap.Models): // TODO: Implement me case key.Matches(msg, m.keyMap.Sessions): - if m.dialog.ContainsDialog(dialog.SessionDialogID) { + if m.dialog.ContainsDialog(dialog.SessionsID) { // Bring to front - m.dialog.BringToFront(dialog.SessionDialogID) + m.dialog.BringToFront(dialog.SessionsID) } else { cmds = append(cmds, m.loadSessionsCmd) } From 3f7de0eaaddd6ada99972a8c84ea74649c6fdb61 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 15 Dec 2025 18:27:17 -0500 Subject: [PATCH 051/335] feat(ui): wip: add commands dialog to show available commands --- internal/ui/dialog/commands.go | 485 ++++++++++++++++++ internal/ui/dialog/commands_item.go | 55 ++ internal/ui/dialog/dialog.go | 10 + internal/ui/dialog/sessions.go | 6 - .../ui/dialog/{items.go => sessions_item.go} | 45 +- internal/ui/model/ui.go | 37 +- internal/ui/styles/styles.go | 6 + 7 files changed, 617 insertions(+), 27 deletions(-) create mode 100644 internal/ui/dialog/commands.go create mode 100644 internal/ui/dialog/commands_item.go rename internal/ui/dialog/{items.go => sessions_item.go} (82%) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go new file mode 100644 index 0000000000000000000000000000000000000000..447d874dbd00d25c09e98f4a78481a1cc3a490af --- /dev/null +++ b/internal/ui/dialog/commands.go @@ -0,0 +1,485 @@ +package dialog + +import ( + "fmt" + "os" + "slices" + "strings" + + "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/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/tui/components/chat" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/uicmd" +) + +// CommandsID is the identifier for the commands dialog. +const CommandsID = "commands" + +// Messages for commands +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 + } +) + +// Commands represents a dialog that shows available commands. +type Commands struct { + com *common.Common + keyMap struct { + Select, + Next, + Previous, + Tab, + Close key.Binding + } + + sessionID string // can be empty for non-session-specific commands + selected uicmd.CommandType + userCmds []uicmd.Command + mcpPrompts *csync.Slice[uicmd.Command] + + help help.Model + input textinput.Model + list *list.FilterableList + width, height int +} + +var _ Dialog = (*Commands)(nil) + +// NewCommands creates a new commands dialog. +func NewCommands(com *common.Common, sessionID string) (*Commands, error) { + commands, err := uicmd.LoadCustomCommandsFromConfig(com.Config()) + if err != nil { + return nil, err + } + + mcpPrompts := csync.NewSlice[uicmd.Command]() + mcpPrompts.SetSlice(uicmd.LoadMCPPrompts()) + + c := &Commands{ + com: com, + userCmds: commands, + selected: uicmd.SystemCommands, + mcpPrompts: mcpPrompts, + sessionID: sessionID, + } + + help := help.New() + help.Styles = com.Styles.DialogHelpStyles() + + c.help = help + + c.list = list.NewFilterableList() + c.list.Focus() + c.list.SetSelected(0) + + c.input = textinput.New() + c.input.SetVirtualCursor(false) + c.input.Placeholder = "Type to filter" + c.input.SetStyles(com.Styles.TextInput) + c.input.Focus() + + c.keyMap.Select = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ) + c.keyMap.Next = key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ) + c.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ) + c.keyMap.Tab = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch selection"), + ) + closeKey := CloseKey + closeKey.SetHelp("esc", "cancel") + c.keyMap.Close = closeKey + + // Set initial commands + c.setCommandType(c.selected) + + return c, nil +} + +// SetSize sets the size of the dialog. +func (c *Commands) SetSize(width, height int) { + c.width = width + c.height = height + innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize() + c.input.SetWidth(innerWidth - c.com.Styles.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) + c.list.SetSize(innerWidth, height-6) // (1) title + (3) input + (1) padding + (1) help + c.help.SetWidth(width) +} + +// ID implements Dialog. +func (c *Commands) ID() string { + return CommandsID +} + +// Update implements Dialog. +func (c *Commands) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, c.keyMap.Previous): + c.list.Focus() + c.list.SelectPrev() + c.list.ScrollToSelected() + case key.Matches(msg, c.keyMap.Next): + c.list.Focus() + c.list.SelectNext() + c.list.ScrollToSelected() + case key.Matches(msg, c.keyMap.Select): + if selectedItem := c.list.SelectedItem(); selectedItem != nil { + if item, ok := selectedItem.(*CommandItem); ok && item != nil { + return item.Cmd.Handler(item.Cmd) // Huh?? + } + } + case key.Matches(msg, c.keyMap.Tab): + if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 { + c.selected = c.nextCommandType() + c.setCommandType(c.selected) + } + default: + var cmd tea.Cmd + c.input, cmd = c.input.Update(msg) + // Update the list filter + c.list.SetFilter(c.input.Value()) + return cmd + } + } + return nil +} + +// ReloadMCPPrompts reloads the MCP prompts. +func (c *Commands) ReloadMCPPrompts() tea.Cmd { + c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts()) + // If we're currently viewing MCP prompts, refresh the list + if c.selected == uicmd.MCPPrompts { + c.setCommandType(uicmd.MCPPrompts) + } + return nil +} + +// Cursor returns the cursor position relative to the dialog. +func (c *Commands) Cursor() *tea.Cursor { + return c.input.Cursor() +} + +// View implements [Dialog]. +func (c *Commands) View() string { + t := c.com.Styles + selectedFn := func(t uicmd.CommandType) string { + if t == c.selected { + return "◉ " + t.String() + } + return "○ " + t.String() + } + + parts := []string{ + selectedFn(uicmd.SystemCommands), + } + if len(c.userCmds) > 0 { + parts = append(parts, selectedFn(uicmd.UserCommands)) + } + if c.mcpPrompts.Len() > 0 { + parts = append(parts, selectedFn(uicmd.MCPPrompts)) + } + + radio := strings.Join(parts, " ") + radio = t.Dialog.Commands.CommandTypeSelector.Render(radio) + if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 { + radio = " " + radio + } + + titleStyle := t.Dialog.Title + helpStyle := t.Dialog.HelpView + dialogStyle := t.Dialog.View.Width(c.width) + inputStyle := t.Dialog.InputPrompt + helpStyle = helpStyle.Width(c.width - dialogStyle.GetHorizontalFrameSize()) + + headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() + header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio + title := titleStyle.Render(header) + help := helpStyle.Render(c.help.View(c)) + listContent := c.list.Render() + if nlines := lipgloss.Height(listContent); nlines < c.list.Height() { + // pad the list content to avoid jumping when navigating + listContent += strings.Repeat("\n", max(0, c.list.Height()-nlines)) + } + + content := strings.Join([]string{ + title, + "", + inputStyle.Render(c.input.View()), + "", + c.list.Render(), + "", + help, + }, "\n") + + return dialogStyle.Render(content) +} + +// ShortHelp implements [help.KeyMap]. +func (c *Commands) ShortHelp() []key.Binding { + upDown := key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ) + return []key.Binding{ + c.keyMap.Tab, + upDown, + c.keyMap.Select, + c.keyMap.Close, + } +} + +// FullHelp implements [help.KeyMap]. +func (c *Commands) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab}, + {c.keyMap.Close}, + } +} + +func (c *Commands) nextCommandType() uicmd.CommandType { + switch c.selected { + case uicmd.SystemCommands: + if len(c.userCmds) > 0 { + return uicmd.UserCommands + } + if c.mcpPrompts.Len() > 0 { + return uicmd.MCPPrompts + } + fallthrough + case uicmd.UserCommands: + if c.mcpPrompts.Len() > 0 { + return uicmd.MCPPrompts + } + fallthrough + case uicmd.MCPPrompts: + return uicmd.SystemCommands + default: + return uicmd.SystemCommands + } +} + +func (c *Commands) setCommandType(commandType uicmd.CommandType) { + c.selected = commandType + + var commands []uicmd.Command + switch c.selected { + case uicmd.SystemCommands: + commands = c.defaultCommands() + case uicmd.UserCommands: + commands = c.userCmds + case uicmd.MCPPrompts: + commands = slices.Collect(c.mcpPrompts.Seq()) + } + + commandItems := []list.FilterableItem{} + for _, cmd := range commands { + commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd)) + } + + c.list.SetItems(commandItems...) + // Reset selection and filter + c.list.SetSelected(0) + c.input.SetValue("") +} + +// TODO: Rethink this +func (c *Commands) defaultCommands() []uicmd.Command { + commands := []uicmd.Command{ + { + ID: "new_session", + Title: "New Session", + Description: "start a new session", + Shortcut: "ctrl+n", + Handler: func(cmd uicmd.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 uicmd.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 uicmd.Command) tea.Cmd { + return util.CmdHandler(SwitchModelMsg{}) + }, + }, + } + + // Only show compact command if there's an active session + if c.sessionID != "" { + commands = append(commands, uicmd.Command{ + ID: "Summarize", + Title: "Summarize Session", + Description: "Summarize the current session and create a new one with the summary", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(CompactMsg{ + SessionID: c.sessionID, + }) + }, + }) + } + + // Add reasoning toggle for models that support it + cfg := c.com.Config() + 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 providerCfg.Type == catwalk.TypeAnthropic { + status := "Enable" + if selectedModel.Think { + status = "Disable" + } + commands = append(commands, uicmd.Command{ + ID: "toggle_thinking", + Title: status + " Thinking Mode", + Description: "Toggle model thinking for reasoning-capable models", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(ToggleThinkingMsg{}) + }, + }) + } + + // OpenAI models: reasoning effort dialog + if len(model.ReasoningLevels) > 0 { + commands = append(commands, uicmd.Command{ + ID: "select_reasoning_effort", + Title: "Select Reasoning Effort", + Description: "Choose reasoning effort level (low/medium/high)", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(OpenReasoningDialogMsg{}) + }, + }) + } + } + } + // Only show toggle compact mode command if window width is larger than compact breakpoint (90) + // TODO: Get. Rid. Of. Magic. Numbers! + if c.width > 120 && c.sessionID != "" { + commands = append(commands, uicmd.Command{ + ID: "toggle_sidebar", + Title: "Toggle Sidebar", + Description: "Toggle between compact and normal layout", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(ToggleCompactModeMsg{}) + }, + }) + } + if c.sessionID != "" { + cfg := c.com.Config() + agentCfg := cfg.Agents[config.AgentCoder] + model := cfg.GetModelByType(agentCfg.Model) + if model.SupportsImages { + commands = append(commands, uicmd.Command{ + ID: "file_picker", + Title: "Open File Picker", + Shortcut: "ctrl+f", + Description: "Open file picker", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(OpenFilePickerMsg{}) + }, + }) + } + } + + // Add external editor command if $EDITOR is available + // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv + if os.Getenv("EDITOR") != "" { + commands = append(commands, uicmd.Command{ + ID: "open_external_editor", + Title: "Open External Editor", + Shortcut: "ctrl+o", + Description: "Open external editor to compose message", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(OpenExternalEditorMsg{}) + }, + }) + } + + return append(commands, []uicmd.Command{ + { + ID: "toggle_yolo", + Title: "Toggle Yolo Mode", + Description: "Toggle yolo mode", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(ToggleYoloModeMsg{}) + }, + }, + { + ID: "toggle_help", + Title: "Toggle Help", + Shortcut: "ctrl+g", + Description: "Toggle help", + Handler: func(cmd uicmd.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 uicmd.Command) tea.Cmd { + initPrompt, err := agent.InitializePrompt(*c.com.Config()) + 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 uicmd.Command) tea.Cmd { + return util.CmdHandler(QuitMsg{}) + }, + }, + }...) +} diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go new file mode 100644 index 0000000000000000000000000000000000000000..79f0aa047ee22a691d117014c33bc7e551b1a29b --- /dev/null +++ b/internal/ui/dialog/commands_item.go @@ -0,0 +1,55 @@ +package dialog + +import ( + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/uicmd" + "github.com/sahilm/fuzzy" +) + +// CommandItem wraps a uicmd.Command to implement the ListItem interface. +type CommandItem struct { + Cmd uicmd.Command + t *styles.Styles + m fuzzy.Match + cache map[int]string + focused bool +} + +var _ ListItem = &CommandItem{} + +// NewCommandItem creates a new CommandItem. +func NewCommandItem(t *styles.Styles, cmd uicmd.Command) *CommandItem { + return &CommandItem{ + Cmd: cmd, + t: t, + } +} + +// Filter implements ListItem. +func (c *CommandItem) Filter() string { + return c.Cmd.Title +} + +// ID implements ListItem. +func (c *CommandItem) ID() string { + return c.Cmd.ID +} + +// SetFocused implements ListItem. +func (c *CommandItem) SetFocused(focused bool) { + if c.focused != focused { + c.cache = nil + } + c.focused = focused +} + +// SetMatch implements ListItem. +func (c *CommandItem) SetMatch(m fuzzy.Match) { + c.cache = nil + c.m = m +} + +// Render implements ListItem. +func (c *CommandItem) Render(width int) string { + return renderItem(c.t, c.Cmd.Title, 0, c.focused, width, c.cache, &c.m) +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 29175922b44015a31182c27e7411c91c47a7f31f..6dc30a9263cf99be1bed0037fd331135f61826b3 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -71,6 +71,16 @@ func (d *Overlay) RemoveDialog(dialogID string) { } } +// Dialog returns the dialog with the specified ID, or nil if not found. +func (d *Overlay) Dialog(dialogID string) Dialog { + for _, dialog := range d.dialogs { + if dialog.ID() == dialogID { + return dialog + } + } + return nil +} + // BringToFront brings the dialog with the specified ID to the front. func (d *Overlay) BringToFront(dialogID string) { for i, dialog := range d.dialogs { diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index ceeeda86dd306f7819ae0b8bc8f7107c6c9c00a0..26fa34d9ace6119da928249ba7753b0cd600ea4f 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -88,12 +88,6 @@ func (s *Session) SetSize(width, height int) { s.help.SetWidth(width) } -// SelectedItem returns the currently selected item. It may be nil if no item -// is selected. -func (s *Session) SelectedItem() list.Item { - return s.list.SelectedItem() -} - // ID implements Dialog. func (s *Session) ID() string { return SessionsID diff --git a/internal/ui/dialog/items.go b/internal/ui/dialog/sessions_item.go similarity index 82% rename from internal/ui/dialog/items.go rename to internal/ui/dialog/sessions_item.go index 3cdc010f9f225d2acbe1cb129c010806a9531987..dbfd939c93530dad95e2f25bce543d9a33f39f5e 100644 --- a/internal/ui/dialog/items.go +++ b/internal/ui/dialog/sessions_item.go @@ -53,50 +53,57 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) { // Render returns the string representation of the session item. func (s *SessionItem) Render(width int) string { - if s.cache == nil { - s.cache = make(map[int]string) + return renderItem(s.t, s.Session.Title, s.Session.UpdatedAt, s.focused, width, s.cache, &s.m) +} + +func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, width int, cache map[int]string, m *fuzzy.Match) string { + if cache == nil { + cache = make(map[int]string) } - cached, ok := s.cache[width] + cached, ok := cache[width] if ok { return cached } - style := s.t.Dialog.NormalItem - if s.focused { - style = s.t.Dialog.SelectedItem + style := t.Dialog.NormalItem + if focused { + style = t.Dialog.SelectedItem } width -= style.GetHorizontalFrameSize() - age := humanize.Time(time.Unix(s.Session.UpdatedAt, 0)) - if s.focused { - age = s.t.Base.Render(age) - } else { - age = s.t.Subtle.Render(age) - } - age = " " + age + var age string + if updatedAt > 0 { + age = humanize.Time(time.Unix(updatedAt, 0)) + if focused { + age = t.Base.Render(age) + } else { + age = t.Subtle.Render(age) + } + + age = " " + age + } ageLen := lipgloss.Width(age) - title := s.Session.Title titleLen := lipgloss.Width(title) title = ansi.Truncate(title, max(0, width-ageLen), "…") right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age) content := title - if matches := len(s.m.MatchedIndexes); matches > 0 { + if matches := len(m.MatchedIndexes); matches > 0 { var lastPos int parts := make([]string, 0) - ranges := matchedRanges(s.m.MatchedIndexes) + ranges := matchedRanges(m.MatchedIndexes) for _, rng := range ranges { start, stop := bytePosToVisibleCharPos(title, rng) if start > lastPos { parts = append(parts, title[lastPos:start]) } - // NOTE: We're using [ansi.Style] here instead of [lipgloss.Style] + // NOTE: We're using [ansi.Style] here instead of [lipglosStyle] // because we can control the underline start and stop more // precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline] // which only affect the underline attribute without interfering - // with other styles. + // with other style parts = append(parts, ansi.NewStyle().Underline(true).String(), title[start:stop+1], @@ -112,7 +119,7 @@ func (s *SessionItem) Render(width int) string { } content = style.Render(content + right) - s.cache[width] = content + cache[width] = content return content } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 24923955acf057458a64ca48083385926d1ad5a4..99a3ba9b942fe92a038e27d686d3d533779b628b 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -20,6 +20,7 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" "github.com/charmbracelet/crush/internal/ui/logo" @@ -190,6 +191,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case sessionsLoadedMsg: sessions := dialog.NewSessions(m.com, msg.sessions...) + // TODO: Get. Rid. Of. Magic numbers! sessions.SetSize(min(120, m.width-8), 30) m.dialog.AddDialog(sessions) case dialog.SessionSelectedMsg: @@ -236,6 +238,19 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lspStates = app.GetLSPStates() case pubsub.Event[mcp.Event]: m.mcpStates = mcp.GetStates() + if msg.Type == pubsub.UpdatedEvent && m.dialog.ContainsDialog(dialog.CommandsID) { + dia := m.dialog.Dialog(dialog.CommandsID) + if dia == nil { + break + } + + commands, ok := dia.(*dialog.Commands) + if ok { + if cmd := commands.ReloadMCPPrompts(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } case tea.TerminalVersionMsg: termVersion := strings.ToLower(msg.Name) // Only enable progress bar for the following terminals. @@ -366,7 +381,23 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { m.updateLayoutAndSize() return true case key.Matches(msg, m.keyMap.Commands): - // TODO: Implement me + if m.dialog.ContainsDialog(dialog.CommandsID) { + // Bring to front + m.dialog.BringToFront(dialog.CommandsID) + } else { + sessionID := "" + if m.session != nil { + sessionID = m.session.ID + } + commands, err := dialog.NewCommands(m.com, sessionID) + if err != nil { + cmds = append(cmds, util.ReportError(err)) + } else { + // TODO: Get. Rid. Of. Magic numbers! + commands.SetSize(min(120, m.width-8), 30) + m.dialog.AddDialog(commands) + } + } case key.Matches(msg, m.keyMap.Models): // TODO: Implement me case key.Matches(msg, m.keyMap.Sessions): @@ -389,7 +420,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { updatedDialog, cmd := m.dialog.Update(msg) m.dialog = updatedDialog - cmds = append(cmds, cmd) + if cmd != nil { + cmds = append(cmds, cmd) + } return cmds } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 8eb077db163fd3eea80eec5e6a4a625d3c3116d6..8eb3562d0221a281bfe559b22e70266da97b56b4 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -276,6 +276,10 @@ type Styles struct { NormalItem lipgloss.Style SelectedItem lipgloss.Style InputPrompt lipgloss.Style + + Commands struct { + CommandTypeSelector lipgloss.Style + } } } @@ -907,6 +911,8 @@ func DefaultStyles() Styles { s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase) s.Dialog.InputPrompt = base.Padding(0, 1) + s.Dialog.Commands.CommandTypeSelector = base.Foreground(fgHalfMuted) + return s } From 2f95dcc6a6ce61554e851fc6eab69aa2f91f5c89 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 15 Dec 2025 18:39:50 -0500 Subject: [PATCH 052/335] refactor(ui): use uiutil for command handling and error reporting --- internal/ui/dialog/commands.go | 39 ++++++++++++++++++++-------------- internal/ui/model/ui.go | 9 ++++++++ 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 447d874dbd00d25c09e98f4a78481a1cc3a490af..f76daab7157b87bba374fa56372c0b25c650dab6 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -15,16 +15,23 @@ import ( "github.com/charmbracelet/crush/internal/agent" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/uicmd" + "github.com/charmbracelet/crush/internal/uiutil" ) // CommandsID is the identifier for the commands dialog. const CommandsID = "commands" +// SendMsg represents a message to send a chat message. +// TODO: Move to chat package? +type SendMsg struct { + Text string + Attachments []message.Attachment +} + // Messages for commands type ( SwitchSessionsMsg struct{} @@ -323,7 +330,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Description: "start a new session", Shortcut: "ctrl+n", Handler: func(cmd uicmd.Command) tea.Cmd { - return util.CmdHandler(NewSessionsMsg{}) + return uiutil.CmdHandler(NewSessionsMsg{}) }, }, { @@ -332,7 +339,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Description: "Switch to a different session", Shortcut: "ctrl+s", Handler: func(cmd uicmd.Command) tea.Cmd { - return util.CmdHandler(SwitchSessionsMsg{}) + return uiutil.CmdHandler(SwitchSessionsMsg{}) }, }, { @@ -341,7 +348,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Description: "Switch to a different model", Shortcut: "ctrl+l", Handler: func(cmd uicmd.Command) tea.Cmd { - return util.CmdHandler(SwitchModelMsg{}) + return uiutil.CmdHandler(SwitchModelMsg{}) }, }, } @@ -353,7 +360,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Title: "Summarize Session", Description: "Summarize the current session and create a new one with the summary", Handler: func(cmd uicmd.Command) tea.Cmd { - return util.CmdHandler(CompactMsg{ + return uiutil.CmdHandler(CompactMsg{ SessionID: c.sessionID, }) }, @@ -379,7 +386,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Title: status + " Thinking Mode", Description: "Toggle model thinking for reasoning-capable models", Handler: func(cmd uicmd.Command) tea.Cmd { - return util.CmdHandler(ToggleThinkingMsg{}) + return uiutil.CmdHandler(ToggleThinkingMsg{}) }, }) } @@ -391,7 +398,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Title: "Select Reasoning Effort", Description: "Choose reasoning effort level (low/medium/high)", Handler: func(cmd uicmd.Command) tea.Cmd { - return util.CmdHandler(OpenReasoningDialogMsg{}) + return uiutil.CmdHandler(OpenReasoningDialogMsg{}) }, }) } @@ -405,7 +412,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Title: "Toggle Sidebar", Description: "Toggle between compact and normal layout", Handler: func(cmd uicmd.Command) tea.Cmd { - return util.CmdHandler(ToggleCompactModeMsg{}) + return uiutil.CmdHandler(ToggleCompactModeMsg{}) }, }) } @@ -420,7 +427,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Shortcut: "ctrl+f", Description: "Open file picker", Handler: func(cmd uicmd.Command) tea.Cmd { - return util.CmdHandler(OpenFilePickerMsg{}) + return uiutil.CmdHandler(OpenFilePickerMsg{}) }, }) } @@ -435,7 +442,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Shortcut: "ctrl+o", Description: "Open external editor to compose message", Handler: func(cmd uicmd.Command) tea.Cmd { - return util.CmdHandler(OpenExternalEditorMsg{}) + return uiutil.CmdHandler(OpenExternalEditorMsg{}) }, }) } @@ -446,7 +453,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Title: "Toggle Yolo Mode", Description: "Toggle yolo mode", Handler: func(cmd uicmd.Command) tea.Cmd { - return util.CmdHandler(ToggleYoloModeMsg{}) + return uiutil.CmdHandler(ToggleYoloModeMsg{}) }, }, { @@ -455,7 +462,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Shortcut: "ctrl+g", Description: "Toggle help", Handler: func(cmd uicmd.Command) tea.Cmd { - return util.CmdHandler(ToggleHelpMsg{}) + return uiutil.CmdHandler(ToggleHelpMsg{}) }, }, { @@ -465,9 +472,9 @@ func (c *Commands) defaultCommands() []uicmd.Command { Handler: func(cmd uicmd.Command) tea.Cmd { initPrompt, err := agent.InitializePrompt(*c.com.Config()) if err != nil { - return util.ReportError(err) + return uiutil.ReportError(err) } - return util.CmdHandler(chat.SendMsg{ + return uiutil.CmdHandler(SendMsg{ Text: initPrompt, }) }, @@ -478,7 +485,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Description: "Quit", Shortcut: "ctrl+c", Handler: func(cmd uicmd.Command) tea.Cmd { - return util.CmdHandler(QuitMsg{}) + return uiutil.CmdHandler(QuitMsg{}) }, }, }...) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 99a3ba9b942fe92a038e27d686d3d533779b628b..cb5e9c6af77dec28e4c21d82311fe906641d6d80 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -330,6 +330,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyPressMsg: cmds = append(cmds, m.handleKeyPressMsg(msg)...) + + // Command dialog messages + // TODO: Properly structure and handle these messages + case dialog.ToggleYoloModeMsg: + m.com.App.Permissions.SetSkipRequests(!m.com.App.Permissions.SkipRequests()) + m.dialog.RemoveDialog(dialog.CommandsID) + case dialog.SwitchSessionsMsg: + cmds = append(cmds, m.loadSessionsCmd) + m.dialog.RemoveDialog(dialog.CommandsID) } // This logic gets triggered on any message type, but should it? From 7fad242ae375f9d8a7b2b6803cfea700b5e558ce Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 16 Dec 2025 12:51:20 -0500 Subject: [PATCH 053/335] refactor(ui): dialog: improve command and session dialogs --- internal/ui/dialog/commands.go | 87 ++++++----------- internal/ui/dialog/common.go | 56 +++++++++++ internal/ui/dialog/dialog.go | 54 +++++++---- internal/ui/dialog/messages.go | 34 +++++++ internal/ui/dialog/quit.go | 14 +-- internal/ui/dialog/sessions.go | 66 ++++--------- internal/ui/model/ui.go | 169 ++++++++++++++++++++------------- internal/ui/styles/styles.go | 6 +- 8 files changed, 291 insertions(+), 195 deletions(-) create mode 100644 internal/ui/dialog/common.go create mode 100644 internal/ui/dialog/messages.go diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index f76daab7157b87bba374fa56372c0b25c650dab6..f9667787430423e01ee0eab6be59bf8ce76f3b70 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -18,6 +18,7 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/uicmd" "github.com/charmbracelet/crush/internal/uiutil" ) @@ -32,24 +33,6 @@ type SendMsg struct { Attachments []message.Attachment } -// Messages for commands -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 - } -) - // Commands represents a dialog that shows available commands. type Commands struct { com *common.Common @@ -149,10 +132,12 @@ func (c *Commands) ID() string { } // Update implements Dialog. -func (c *Commands) Update(msg tea.Msg) tea.Cmd { +func (c *Commands) Update(msg tea.Msg) tea.Msg { switch msg := msg.(type) { case tea.KeyPressMsg: switch { + case key.Matches(msg, c.keyMap.Close): + return CloseMsg{} case key.Matches(msg, c.keyMap.Previous): c.list.Focus() c.list.SelectPrev() @@ -175,8 +160,10 @@ func (c *Commands) Update(msg tea.Msg) tea.Cmd { default: var cmd tea.Cmd c.input, cmd = c.input.Update(msg) - // Update the list filter - c.list.SetFilter(c.input.Value()) + value := c.input.Value() + c.list.SetFilter(value) + c.list.ScrollToTop() + c.list.SetSelected(0) return cmd } } @@ -195,14 +182,17 @@ func (c *Commands) ReloadMCPPrompts() tea.Cmd { // Cursor returns the cursor position relative to the dialog. func (c *Commands) Cursor() *tea.Cursor { - return c.input.Cursor() + return InputCursor(c.com.Styles, c.input.Cursor()) } -// View implements [Dialog]. -func (c *Commands) View() string { - t := c.com.Styles +// radioView generates the command type selector radio buttons. +func radioView(t *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string { + if !hasUserCmds && !hasMCPPrompts { + return "" + } + selectedFn := func(t uicmd.CommandType) string { - if t == c.selected { + if t == selected { return "◉ " + t.String() } return "○ " + t.String() @@ -211,46 +201,27 @@ func (c *Commands) View() string { parts := []string{ selectedFn(uicmd.SystemCommands), } - if len(c.userCmds) > 0 { + if hasUserCmds { parts = append(parts, selectedFn(uicmd.UserCommands)) } - if c.mcpPrompts.Len() > 0 { + if hasMCPPrompts { parts = append(parts, selectedFn(uicmd.MCPPrompts)) } radio := strings.Join(parts, " ") - radio = t.Dialog.Commands.CommandTypeSelector.Render(radio) - if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 { - radio = " " + radio - } + return t.Dialog.Commands.CommandTypeSelector.Render(radio) +} +// View implements [Dialog]. +func (c *Commands) View() string { + t := c.com.Styles + radio := radioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0) titleStyle := t.Dialog.Title - helpStyle := t.Dialog.HelpView dialogStyle := t.Dialog.View.Width(c.width) - inputStyle := t.Dialog.InputPrompt - helpStyle = helpStyle.Width(c.width - dialogStyle.GetHorizontalFrameSize()) - headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio - title := titleStyle.Render(header) - help := helpStyle.Render(c.help.View(c)) - listContent := c.list.Render() - if nlines := lipgloss.Height(listContent); nlines < c.list.Height() { - // pad the list content to avoid jumping when navigating - listContent += strings.Repeat("\n", max(0, c.list.Height()-nlines)) - } - - content := strings.Join([]string{ - title, - "", - inputStyle.Render(c.input.View()), - "", - c.list.Render(), - "", - help, - }, "\n") - - return dialogStyle.Render(content) + return HeaderInputListHelpView(t, c.width, c.list.Height(), header, + c.input.View(), c.list.Render(), c.help.View(c)) } // ShortHelp implements [help.KeyMap]. @@ -316,7 +287,9 @@ func (c *Commands) setCommandType(commandType uicmd.CommandType) { } c.list.SetItems(commandItems...) - // Reset selection and filter + c.list.SetSelected(0) + c.list.SetFilter("") + c.list.ScrollToTop() c.list.SetSelected(0) c.input.SetValue("") } @@ -485,7 +458,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Description: "Quit", Shortcut: "ctrl+c", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(QuitMsg{}) + return uiutil.CmdHandler(tea.QuitMsg{}) }, }, }...) diff --git a/internal/ui/dialog/common.go b/internal/ui/dialog/common.go new file mode 100644 index 0000000000000000000000000000000000000000..48234281f304208b9e1a30c575fab342ceb5e57a --- /dev/null +++ b/internal/ui/dialog/common.go @@ -0,0 +1,56 @@ +package dialog + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// InputCursor adjusts the cursor position for an input field within a dialog. +func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor { + if cur != nil { + titleStyle := t.Dialog.Title + dialogStyle := t.Dialog.View + inputStyle := t.Dialog.InputPrompt + // Adjust cursor position to account for dialog layout + cur.X += inputStyle.GetBorderLeftSize() + + inputStyle.GetMarginLeft() + + inputStyle.GetPaddingLeft() + + dialogStyle.GetBorderLeftSize() + + dialogStyle.GetPaddingLeft() + + dialogStyle.GetMarginLeft() + cur.Y += titleStyle.GetVerticalFrameSize() + + inputStyle.GetBorderTopSize() + + inputStyle.GetMarginTop() + + inputStyle.GetPaddingTop() + + inputStyle.GetBorderBottomSize() + + inputStyle.GetMarginBottom() + + inputStyle.GetPaddingBottom() + + dialogStyle.GetPaddingTop() + + dialogStyle.GetMarginTop() + + dialogStyle.GetBorderTopSize() + } + return cur +} + +// HeaderInputListHelpView generates a view for dialogs with a header, input, +// list, and help sections. +func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, input, list, help string) string { + titleStyle := t.Dialog.Title + helpStyle := t.Dialog.HelpView + dialogStyle := t.Dialog.View.Width(width) + inputStyle := t.Dialog.InputPrompt + helpStyle = helpStyle.Width(width - dialogStyle.GetHorizontalFrameSize()) + listStyle := t.Dialog.List.Height(listHeight) + listContent := listStyle.Render(list) + + content := strings.Join([]string{ + titleStyle.Render(header), + inputStyle.Render(input), + listContent, + helpStyle.Render(help), + }, "\n") + + return dialogStyle.Render(content) +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 6dc30a9263cf99be1bed0037fd331135f61826b3..ec9472dc892c1a095abb284897966ac92b259dc4 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -17,7 +17,7 @@ var CloseKey = key.NewBinding( // Dialog is a component that can be displayed on top of the UI. type Dialog interface { ID() string - Update(msg tea.Msg) tea.Cmd + Update(msg tea.Msg) tea.Msg View() string } @@ -71,6 +71,14 @@ func (d *Overlay) RemoveDialog(dialogID string) { } } +// RemoveFrontDialog removes the front dialog from the stack. +func (d *Overlay) RemoveFrontDialog() { + if len(d.dialogs) == 0 { + return + } + d.removeDialog(len(d.dialogs) - 1) +} + // Dialog returns the dialog with the specified ID, or nil if not found. func (d *Overlay) Dialog(dialogID string) Dialog { for _, dialog := range d.dialogs { @@ -81,6 +89,14 @@ func (d *Overlay) Dialog(dialogID string) Dialog { return nil } +// DialogLast returns the front dialog, or nil if there are no dialogs. +func (d *Overlay) DialogLast() Dialog { + if len(d.dialogs) == 0 { + return nil + } + return d.dialogs[len(d.dialogs)-1] +} + // BringToFront brings the dialog with the specified ID to the front. func (d *Overlay) BringToFront(dialogID string) { for i, dialog := range d.dialogs { @@ -94,38 +110,40 @@ func (d *Overlay) BringToFront(dialogID string) { } // Update handles dialog updates. -func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) { +func (d *Overlay) Update(msg tea.Msg) tea.Msg { if len(d.dialogs) == 0 { - return d, nil + return nil } idx := len(d.dialogs) - 1 // active dialog is the last one dialog := d.dialogs[idx] - switch msg := msg.(type) { - case tea.KeyPressMsg: - if key.Matches(msg, CloseKey) { - // Close the current dialog - d.removeDialog(idx) - return d, nil - } + if dialog == nil { + return nil } - if cmd := dialog.Update(msg); cmd != nil { - // Close the current dialog - d.removeDialog(idx) - return d, cmd + return dialog.Update(msg) +} + +// CenterPosition calculates the centered position for the dialog. +func (d *Overlay) CenterPosition(area uv.Rectangle, dialogID string) uv.Rectangle { + dialog := d.Dialog(dialogID) + if dialog == nil { + return uv.Rectangle{} } + return d.centerPositionView(area, dialog.View()) +} - return d, nil +func (d *Overlay) centerPositionView(area uv.Rectangle, view string) uv.Rectangle { + viewWidth := lipgloss.Width(view) + viewHeight := lipgloss.Height(view) + return common.CenterRect(area, viewWidth, viewHeight) } // Draw renders the overlay and its dialogs. func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) { for _, dialog := range d.dialogs { view := dialog.View() - viewWidth := lipgloss.Width(view) - viewHeight := lipgloss.Height(view) - center := common.CenterRect(area, viewWidth, viewHeight) + center := d.centerPositionView(area, view) if area.Overlaps(center) { uv.NewStyledString(view).Draw(scr, center) } diff --git a/internal/ui/dialog/messages.go b/internal/ui/dialog/messages.go new file mode 100644 index 0000000000000000000000000000000000000000..b3981ce382ea8712e93bd075600b3455748cb579 --- /dev/null +++ b/internal/ui/dialog/messages.go @@ -0,0 +1,34 @@ +package dialog + +import ( + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/session" +) + +// CloseMsg is a message to close the current dialog. +type CloseMsg struct{} + +// QuitMsg is a message to quit the application. +type QuitMsg = tea.QuitMsg + +// SessionSelectedMsg is a message indicating a session has been selected. +type SessionSelectedMsg struct { + Session session.Session +} + +// Messages for commands +type ( + SwitchSessionsMsg struct{} + NewSessionsMsg struct{} + SwitchModelMsg struct{} + OpenFilePickerMsg struct{} + ToggleHelpMsg struct{} + ToggleCompactModeMsg struct{} + ToggleThinkingMsg struct{} + OpenReasoningDialogMsg struct{} + OpenExternalEditorMsg struct{} + ToggleYoloModeMsg struct{} + CompactMsg struct { + SessionID string + } +) diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go index 4891d62d1f7c9933f2e231ba50c42aa71ca0f2c0..8f687571c8789901ac7cadc464cc4aecf53698db 100644 --- a/internal/ui/dialog/quit.go +++ b/internal/ui/dialog/quit.go @@ -60,22 +60,24 @@ func (*Quit) ID() string { } // Update implements [Model]. -func (q *Quit) Update(msg tea.Msg) tea.Cmd { +func (q *Quit) Update(msg tea.Msg) tea.Msg { switch msg := msg.(type) { case tea.KeyPressMsg: switch { + case key.Matches(msg, q.keyMap.Close): + return CloseMsg{} case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab): q.selectedNo = !q.selectedNo - return nil + return CloseMsg{} case key.Matches(msg, q.keyMap.EnterSpace): if !q.selectedNo { - return tea.Quit + return QuitMsg{} } - return nil + return CloseMsg{} case key.Matches(msg, q.keyMap.Yes): - return tea.Quit + return QuitMsg{} case key.Matches(msg, q.keyMap.No, q.keyMap.Close): - return nil + return CloseMsg{} } } diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 26fa34d9ace6119da928249ba7753b0cd600ea4f..016bd1a8f79373296ecbec7acf2e36b97dae8ff5 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -1,13 +1,10 @@ package dialog import ( - "strings" - "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/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" @@ -34,11 +31,6 @@ type Session struct { var _ Dialog = (*Session)(nil) -// SessionSelectedMsg is a message sent when a session is selected. -type SessionSelectedMsg struct { - Session session.Session -} - // NewSessions creates a new Session dialog. func NewSessions(com *common.Common, sessions ...session.Session) *Session { s := new(Session) @@ -73,11 +65,6 @@ func NewSessions(com *common.Common, sessions ...session.Session) *Session { return s } -// Cursor returns the cursor position relative to the dialog. -func (s *Session) Cursor() *tea.Cursor { - return s.input.Cursor() -} - // SetSize sets the size of the dialog. func (s *Session) SetSize(width, height int) { s.width = width @@ -94,10 +81,12 @@ func (s *Session) ID() string { } // Update implements Dialog. -func (s *Session) Update(msg tea.Msg) tea.Cmd { +func (s *Session) Update(msg tea.Msg) tea.Msg { switch msg := msg.(type) { case tea.KeyPressMsg: switch { + case key.Matches(msg, s.keyMap.Close): + return CloseMsg{} case key.Matches(msg, s.keyMap.Previous): s.list.Focus() s.list.SelectPrev() @@ -109,48 +98,36 @@ func (s *Session) Update(msg tea.Msg) tea.Cmd { case key.Matches(msg, s.keyMap.Select): if item := s.list.SelectedItem(); item != nil { sessionItem := item.(*SessionItem) - return SessionSelectCmd(sessionItem.Session) + return SessionSelectedMsg{sessionItem.Session} } default: var cmd tea.Cmd s.input, cmd = s.input.Update(msg) - s.list.SetFilter(s.input.Value()) + value := s.input.Value() + s.list.SetFilter(value) + s.list.ScrollToTop() + s.list.SetSelected(0) return cmd } } return nil } +// Cursor returns the cursor position relative to the dialog. +func (s *Session) Cursor() *tea.Cursor { + return InputCursor(s.com.Styles, s.input.Cursor()) +} + // View implements [Dialog]. func (s *Session) View() string { titleStyle := s.com.Styles.Dialog.Title - helpStyle := s.com.Styles.Dialog.HelpView dialogStyle := s.com.Styles.Dialog.View.Width(s.width) - inputStyle := s.com.Styles.Dialog.InputPrompt - helpStyle = helpStyle.Width(s.width - dialogStyle.GetHorizontalFrameSize()) - listContent := s.list.Render() - if nlines := lipgloss.Height(listContent); nlines < s.list.Height() { - // pad the list content to avoid jumping when navigating - listContent += strings.Repeat("\n", max(0, s.list.Height()-nlines)) - } + header := common.DialogTitle(s.com.Styles, "Switch Session", + max(0, s.width-dialogStyle.GetHorizontalFrameSize()- + titleStyle.GetHorizontalFrameSize())) - content := strings.Join([]string{ - titleStyle.Render( - common.DialogTitle( - s.com.Styles, - "Switch Session", - max(0, s.width- - dialogStyle.GetHorizontalFrameSize()- - titleStyle.GetHorizontalFrameSize()))), - "", - inputStyle.Render(s.input.View()), - "", - listContent, - "", - helpStyle.Render(s.help.View(s)), - }, "\n") - - return dialogStyle.Render(content) + return HeaderInputListHelpView(s.com.Styles, s.width, s.list.Height(), header, + s.input.View(), s.list.Render(), s.help.View(s)) } // ShortHelp implements [help.KeyMap]. @@ -181,10 +158,3 @@ func (s *Session) FullHelp() [][]key.Binding { } return m } - -// SessionSelectCmd creates a command that sends a SessionSelectMsg. -func SessionSelectCmd(s session.Session) tea.Cmd { - return func() tea.Msg { - return SessionSelectedMsg{Session: s} - } -} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index cb5e9c6af77dec28e4c21d82311fe906641d6d80..2755410947ac41e62951c42c11e514c40727abce 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -20,11 +20,11 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/crush/internal/ui/common" "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/version" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/ultraviolet/screen" @@ -194,12 +194,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // TODO: Get. Rid. Of. Magic numbers! sessions.SetSize(min(120, m.width-8), 30) m.dialog.AddDialog(sessions) - case dialog.SessionSelectedMsg: - m.dialog.RemoveDialog(dialog.SessionsID) - cmds = append(cmds, - m.loadSession(msg.Session.ID), - m.loadSessionFiles(msg.Session.ID), - ) case sessionLoadedMsg: m.state = uiChat m.session = &msg.sess @@ -330,15 +324,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyPressMsg: cmds = append(cmds, m.handleKeyPressMsg(msg)...) - - // Command dialog messages - // TODO: Properly structure and handle these messages - case dialog.ToggleYoloModeMsg: - m.com.App.Permissions.SetSkipRequests(!m.com.App.Permissions.SkipRequests()) - m.dialog.RemoveDialog(dialog.CommandsID) - case dialog.SwitchSessionsMsg: - cmds = append(cmds, m.loadSessionsCmd) - m.dialog.RemoveDialog(dialog.CommandsID) } // This logic gets triggered on any message type, but should it? @@ -384,7 +369,6 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { return true } switch { - case key.Matches(msg, m.keyMap.Tab): case key.Matches(msg, m.keyMap.Help): m.help.ShowAll = !m.help.ShowAll m.updateLayoutAndSize() @@ -400,7 +384,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { } commands, err := dialog.NewCommands(m.com, sessionID) if err != nil { - cmds = append(cmds, util.ReportError(err)) + cmds = append(cmds, uiutil.ReportError(err)) } else { // TODO: Get. Rid. Of. Magic numbers! commands.SetSize(min(120, m.width-8), 30) @@ -427,64 +411,101 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { return cmds } - updatedDialog, cmd := m.dialog.Update(msg) - m.dialog = updatedDialog - if cmd != nil { - cmds = append(cmds, cmd) + msg := m.dialog.Update(msg) + if msg == nil { + return cmds } + + switch msg := msg.(type) { + // Generic dialog messages + case dialog.CloseMsg: + m.dialog.RemoveFrontDialog() + // Session dialog messages + case dialog.SessionSelectedMsg: + m.dialog.RemoveDialog(dialog.SessionsID) + cmds = append(cmds, + m.loadSession(msg.Session.ID), + m.loadSessionFiles(msg.Session.ID), + ) + // Command dialog messages + case dialog.ToggleYoloModeMsg: + m.com.App.Permissions.SetSkipRequests(!m.com.App.Permissions.SkipRequests()) + m.dialog.RemoveDialog(dialog.CommandsID) + case dialog.SwitchSessionsMsg: + cmds = append(cmds, m.loadSessionsCmd) + m.dialog.RemoveDialog(dialog.CommandsID) + case dialog.CompactMsg: + err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) + if err != nil { + cmds = append(cmds, uiutil.ReportError(err)) + } + case dialog.ToggleHelpMsg: + m.help.ShowAll = !m.help.ShowAll + case dialog.QuitMsg: + cmds = append(cmds, tea.Quit) + } + return cmds } switch m.state { case uiChat: - switch { - case key.Matches(msg, m.keyMap.Tab): - if m.focus == uiFocusMain { - m.focus = uiFocusEditor - cmds = append(cmds, m.textarea.Focus()) - m.chat.Blur() - } else { + switch m.focus { + case uiFocusEditor: + switch { + case key.Matches(msg, m.keyMap.Tab): m.focus = uiFocusMain m.textarea.Blur() m.chat.Focus() m.chat.SetSelected(m.chat.Len() - 1) + default: + handleGlobalKeys(msg) } - case key.Matches(msg, m.keyMap.Chat.Up): - m.chat.ScrollBy(-1) - if !m.chat.SelectedItemInView() { + case uiFocusMain: + switch { + case key.Matches(msg, m.keyMap.Tab): + m.focus = uiFocusEditor + cmds = append(cmds, m.textarea.Focus()) + m.chat.Blur() + case key.Matches(msg, m.keyMap.Chat.Up): + m.chat.ScrollBy(-1) + if !m.chat.SelectedItemInView() { + m.chat.SelectPrev() + m.chat.ScrollToSelected() + } + case key.Matches(msg, m.keyMap.Chat.Down): + m.chat.ScrollBy(1) + if !m.chat.SelectedItemInView() { + m.chat.SelectNext() + m.chat.ScrollToSelected() + } + case key.Matches(msg, m.keyMap.Chat.UpOneItem): m.chat.SelectPrev() m.chat.ScrollToSelected() - } - case key.Matches(msg, m.keyMap.Chat.Down): - m.chat.ScrollBy(1) - if !m.chat.SelectedItemInView() { + case key.Matches(msg, m.keyMap.Chat.DownOneItem): m.chat.SelectNext() m.chat.ScrollToSelected() + case key.Matches(msg, m.keyMap.Chat.HalfPageUp): + m.chat.ScrollBy(-m.chat.Height() / 2) + m.chat.SelectFirstInView() + case key.Matches(msg, m.keyMap.Chat.HalfPageDown): + m.chat.ScrollBy(m.chat.Height() / 2) + m.chat.SelectLastInView() + case key.Matches(msg, m.keyMap.Chat.PageUp): + m.chat.ScrollBy(-m.chat.Height()) + m.chat.SelectFirstInView() + case key.Matches(msg, m.keyMap.Chat.PageDown): + m.chat.ScrollBy(m.chat.Height()) + m.chat.SelectLastInView() + case key.Matches(msg, m.keyMap.Chat.Home): + m.chat.ScrollToTop() + m.chat.SelectFirst() + case key.Matches(msg, m.keyMap.Chat.End): + m.chat.ScrollToBottom() + m.chat.SelectLast() + default: + handleGlobalKeys(msg) } - case key.Matches(msg, m.keyMap.Chat.UpOneItem): - m.chat.SelectPrev() - m.chat.ScrollToSelected() - case key.Matches(msg, m.keyMap.Chat.DownOneItem): - m.chat.SelectNext() - m.chat.ScrollToSelected() - case key.Matches(msg, m.keyMap.Chat.HalfPageUp): - m.chat.ScrollBy(-m.chat.Height() / 2) - m.chat.SelectFirstInView() - case key.Matches(msg, m.keyMap.Chat.HalfPageDown): - m.chat.ScrollBy(m.chat.Height() / 2) - m.chat.SelectLastInView() - case key.Matches(msg, m.keyMap.Chat.PageUp): - m.chat.ScrollBy(-m.chat.Height()) - m.chat.SelectFirstInView() - case key.Matches(msg, m.keyMap.Chat.PageDown): - m.chat.ScrollBy(m.chat.Height()) - m.chat.SelectLastInView() - case key.Matches(msg, m.keyMap.Chat.Home): - m.chat.ScrollToTop() - m.chat.SelectFirst() - case key.Matches(msg, m.keyMap.Chat.End): - m.chat.ScrollToBottom() - m.chat.SelectLast() default: handleGlobalKeys(msg) } @@ -588,11 +609,29 @@ func (m *UI) Cursor() *tea.Cursor { // Don't show cursor if editor is not visible return nil } - if m.focus == uiFocusEditor && m.textarea.Focused() { - cur := m.textarea.Cursor() - cur.X++ // Adjust for app margins - cur.Y += m.layout.editor.Min.Y - return cur + if m.dialog.HasDialogs() { + if front := m.dialog.DialogLast(); front != nil { + c, ok := front.(uiutil.Cursor) + if ok { + cur := c.Cursor() + if cur != nil { + pos := m.dialog.CenterPosition(m.layout.area, front.ID()) + cur.X += pos.Min.X + cur.Y += pos.Min.Y + return cur + } + } + } + return nil + } + switch m.focus { + case uiFocusEditor: + if m.textarea.Focused() { + cur := m.textarea.Cursor() + cur.X++ // Adjust for app margins + cur.Y += m.layout.editor.Min.Y + return cur + } } return nil } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 8eb3562d0221a281bfe559b22e70266da97b56b4..7690bdb704de6338754d931373fcf29924efc6a8 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -277,6 +277,8 @@ type Styles struct { SelectedItem lipgloss.Style InputPrompt lipgloss.Style + List lipgloss.Style + Commands struct { CommandTypeSelector lipgloss.Style } @@ -909,7 +911,9 @@ func DefaultStyles() Styles { s.Dialog.Help.FullSeparator = base.Foreground(border) s.Dialog.NormalItem = base.Padding(0, 1).Foreground(fgBase) s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase) - s.Dialog.InputPrompt = base.Padding(0, 1) + s.Dialog.InputPrompt = base.Margin(1, 1) + + s.Dialog.List = base.Margin(0, 0, 1, 0) s.Dialog.Commands.CommandTypeSelector = base.Foreground(fgHalfMuted) From c74a5c4d64491606fbeff0eda109b7eae5e4e52c Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 16 Dec 2025 12:55:40 -0500 Subject: [PATCH 054/335] fix(dialog): commands: execute command handlers properly --- internal/ui/dialog/commands.go | 6 +++++- internal/ui/model/ui.go | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index f9667787430423e01ee0eab6be59bf8ce76f3b70..d6fbfa750f27e7b6641b543d417a5b4aa07c0bbc 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -149,7 +149,11 @@ func (c *Commands) Update(msg tea.Msg) tea.Msg { case key.Matches(msg, c.keyMap.Select): if selectedItem := c.list.SelectedItem(); selectedItem != nil { if item, ok := selectedItem.(*CommandItem); ok && item != nil { - return item.Cmd.Handler(item.Cmd) // Huh?? + // TODO: Please unravel this mess later and the Command + // Handler design. + if cmd := item.Cmd.Handler(item.Cmd); cmd != nil { // Huh?? + return cmd() + } } } case key.Matches(msg, c.keyMap.Tab): diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 2755410947ac41e62951c42c11e514c40727abce..fac8ccd7f1813c1f839a34bee28c6bfcd6bdc0b0 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -441,6 +441,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { } case dialog.ToggleHelpMsg: m.help.ShowAll = !m.help.ShowAll + m.dialog.RemoveDialog(dialog.CommandsID) case dialog.QuitMsg: cmds = append(cmds, tea.Quit) } From e24c9c387693b83baa43d4cfc7deaa274a74661b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 16 Dec 2025 13:01:19 -0500 Subject: [PATCH 055/335] refactor(ui): dialog: rename Add/Remove to Open/Close --- internal/ui/dialog/dialog.go | 20 ++++++-------------- internal/ui/model/ui.go | 16 ++++++++-------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index ec9472dc892c1a095abb284897966ac92b259dc4..2796ea16a78b24e0349ac30f1a4485271deae51e 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -33,14 +33,6 @@ func NewOverlay(dialogs ...Dialog) *Overlay { } } -// IsFrontDialog checks if the dialog with the specified ID is at the front. -func (d *Overlay) IsFrontDialog(dialogID string) bool { - if len(d.dialogs) == 0 { - return false - } - return d.dialogs[len(d.dialogs)-1].ID() == dialogID -} - // HasDialogs checks if there are any active dialogs. func (d *Overlay) HasDialogs() bool { return len(d.dialogs) > 0 @@ -56,13 +48,13 @@ func (d *Overlay) ContainsDialog(dialogID string) bool { return false } -// AddDialog adds a new dialog to the stack. -func (d *Overlay) AddDialog(dialog Dialog) { +// OpenDialog opens a new dialog to the stack. +func (d *Overlay) OpenDialog(dialog Dialog) { d.dialogs = append(d.dialogs, dialog) } -// RemoveDialog removes the dialog with the specified ID from the stack. -func (d *Overlay) RemoveDialog(dialogID string) { +// CloseDialog closes the dialog with the specified ID from the stack. +func (d *Overlay) CloseDialog(dialogID string) { for i, dialog := range d.dialogs { if dialog.ID() == dialogID { d.removeDialog(i) @@ -71,8 +63,8 @@ func (d *Overlay) RemoveDialog(dialogID string) { } } -// RemoveFrontDialog removes the front dialog from the stack. -func (d *Overlay) RemoveFrontDialog() { +// CloseFrontDialog closes the front dialog in the stack. +func (d *Overlay) CloseFrontDialog() { if len(d.dialogs) == 0 { return } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index fac8ccd7f1813c1f839a34bee28c6bfcd6bdc0b0..713dfb1a33f4338dc0e436e585815696bbcc9dd2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -193,7 +193,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { sessions := dialog.NewSessions(m.com, msg.sessions...) // TODO: Get. Rid. Of. Magic numbers! sessions.SetSize(min(120, m.width-8), 30) - m.dialog.AddDialog(sessions) + m.dialog.OpenDialog(sessions) case sessionLoadedMsg: m.state = uiChat m.session = &msg.sess @@ -357,7 +357,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { switch { case key.Matches(msg, m.keyMap.Quit): if !m.dialog.ContainsDialog(dialog.QuitID) { - m.dialog.AddDialog(dialog.NewQuit(m.com)) + m.dialog.OpenDialog(dialog.NewQuit(m.com)) return true } } @@ -388,7 +388,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { } else { // TODO: Get. Rid. Of. Magic numbers! commands.SetSize(min(120, m.width-8), 30) - m.dialog.AddDialog(commands) + m.dialog.OpenDialog(commands) } } case key.Matches(msg, m.keyMap.Models): @@ -419,10 +419,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { switch msg := msg.(type) { // Generic dialog messages case dialog.CloseMsg: - m.dialog.RemoveFrontDialog() + m.dialog.CloseFrontDialog() // Session dialog messages case dialog.SessionSelectedMsg: - m.dialog.RemoveDialog(dialog.SessionsID) + m.dialog.CloseDialog(dialog.SessionsID) cmds = append(cmds, m.loadSession(msg.Session.ID), m.loadSessionFiles(msg.Session.ID), @@ -430,10 +430,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { // Command dialog messages case dialog.ToggleYoloModeMsg: m.com.App.Permissions.SetSkipRequests(!m.com.App.Permissions.SkipRequests()) - m.dialog.RemoveDialog(dialog.CommandsID) + m.dialog.CloseDialog(dialog.CommandsID) case dialog.SwitchSessionsMsg: cmds = append(cmds, m.loadSessionsCmd) - m.dialog.RemoveDialog(dialog.CommandsID) + m.dialog.CloseDialog(dialog.CommandsID) case dialog.CompactMsg: err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) if err != nil { @@ -441,7 +441,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { } case dialog.ToggleHelpMsg: m.help.ShowAll = !m.help.ShowAll - m.dialog.RemoveDialog(dialog.CommandsID) + m.dialog.CloseDialog(dialog.CommandsID) case dialog.QuitMsg: cmds = append(cmds, tea.Quit) } From e5932974c5cbc41e1dde2624fc38be3e5d96ab3e Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 16 Dec 2025 13:36:08 -0500 Subject: [PATCH 056/335] refactor(ui): rename files.go to session.go and update session loading logic --- internal/ui/model/{files.go => session.go} | 42 +++++-- internal/ui/model/ui.go | 134 +++++++++++---------- 2 files changed, 102 insertions(+), 74 deletions(-) rename internal/ui/model/{files.go => session.go} (82%) diff --git a/internal/ui/model/files.go b/internal/ui/model/session.go similarity index 82% rename from internal/ui/model/files.go rename to internal/ui/model/session.go index 7526ee8473f591b298e1a89096c5c8db5225bb70..065a17ad49b7d14092fc6bb868390e522e5eeaa8 100644 --- a/internal/ui/model/files.go +++ b/internal/ui/model/session.go @@ -12,11 +12,20 @@ import ( "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/history" + "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/x/ansi" ) +// loadSessionMsg is a message indicating that a session and its files have +// been loaded. +type loadSessionMsg struct { + session *session.Session + files []SessionFile +} + // SessionFile tracks the first and latest versions of a file in a session, // along with the total additions and deletions. type SessionFile struct { @@ -26,14 +35,24 @@ type SessionFile struct { Deletions int } -// loadSessionFiles loads all files modified during a session and calculates -// their diff statistics. -func (m *UI) loadSessionFiles(sessionID string) tea.Cmd { +// loadSession loads the session along with its associated files and computes +// the diff statistics (additions and deletions) for each file in the session. +// It returns a tea.Cmd that, when executed, fetches the session data and +// returns a sessionFilesLoadedMsg containing the processed session files. +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 uiutil.ReportError(err)() + } + files, err := m.com.App.History.ListBySession(context.Background(), sessionID) if err != nil { - return err + // TODO: better error handling + return uiutil.ReportError(err)() } + filesByPath := make(map[string][]history.File) for _, f := range files { filesByPath[f.Path] = append(filesByPath[f.Path], f) @@ -76,8 +95,9 @@ func (m *UI) loadSessionFiles(sessionID string) tea.Cmd { return 0 }) - return sessionFilesLoadedMsg{ - files: sessionFiles, + return loadSessionMsg{ + session: &session, + files: sessionFiles, } } } @@ -108,7 +128,10 @@ func (m *UI) handleFileEvent(file history.File) tea.Cmd { }) newFiles = append(newFiles, m.sessionFiles...) - return sessionFilesLoadedMsg{files: newFiles} + return loadSessionMsg{ + session: m.session, + files: newFiles, + } } updated := m.sessionFiles[existingIdx] @@ -137,7 +160,10 @@ func (m *UI) handleFileEvent(file history.File) tea.Cmd { } } - return sessionFilesLoadedMsg{files: newFiles} + return loadSessionMsg{ + session: m.session, + files: newFiles, + } } } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 713dfb1a33f4338dc0e436e585815696bbcc9dd2..bc8d1bb17ac9e1c651cf3f3aac150047ee16fa7c 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -51,19 +51,11 @@ const ( uiChatCompact ) -// sessionsLoadedMsg is a message indicating that sessions have been loaded. -type sessionsLoadedMsg struct { +// listSessionsMsg is a message to list available sessions. +type listSessionsMsg struct { sessions []session.Session } -type sessionLoadedMsg struct { - sess session.Session -} - -type sessionFilesLoadedMsg struct { - files []SessionFile -} - // UI represents the main user interface model. type UI struct { com *common.Common @@ -176,10 +168,6 @@ func (m *UI) Init() tea.Cmd { return tea.Batch(cmds...) } -// sessionLoadedDoneMsg indicates that session loading and message appending is -// done. -type sessionLoadedDoneMsg struct{} - // Update handles updates to the UI model. func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -189,16 +177,19 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.sendProgressBar { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } - case sessionsLoadedMsg: - sessions := dialog.NewSessions(m.com, msg.sessions...) - // TODO: Get. Rid. Of. Magic numbers! - sessions.SetSize(min(120, m.width-8), 30) - m.dialog.OpenDialog(sessions) - case sessionLoadedMsg: + case listSessionsMsg: + if cmd := m.openSessionsDialog(msg.sessions); cmd != nil { + cmds = append(cmds, cmd) + } + case loadSessionMsg: m.state = uiChat - m.session = &msg.sess - // Load the last 20 messages from this session. - msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID) + m.session = msg.session + 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)) + break + } // Build tool result map to link tool calls with their results msgPtrs := make([]*message.Message, len(msgs)) @@ -215,17 +206,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.chat.SetMessages(items...) - // Notify that session loading is done to scroll to bottom. This is - // needed because we need to draw the chat list first before we can - // scroll to bottom. - cmds = append(cmds, func() tea.Msg { - return sessionLoadedDoneMsg{} - }) - case sessionLoadedDoneMsg: m.chat.ScrollToBottom() m.chat.SelectLast() - case sessionFilesLoadedMsg: - m.sessionFiles = msg.files case pubsub.Event[history.File]: cmds = append(cmds, m.handleFileEvent(msg.Payload)) case pubsub.Event[app.LSPEvent]: @@ -344,14 +326,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *UI) loadSession(sessionID string) tea.Cmd { - return func() tea.Msg { - // TODO: handle error - session, _ := m.com.App.Sessions.Get(context.Background(), sessionID) - return sessionLoadedMsg{session} - } -} - func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { handleQuitKeys := func(msg tea.KeyPressMsg) bool { switch { @@ -374,23 +348,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { m.updateLayoutAndSize() return true case key.Matches(msg, m.keyMap.Commands): - if m.dialog.ContainsDialog(dialog.CommandsID) { - // Bring to front - m.dialog.BringToFront(dialog.CommandsID) - } else { - sessionID := "" - if m.session != nil { - sessionID = m.session.ID - } - commands, err := dialog.NewCommands(m.com, sessionID) - if err != nil { - cmds = append(cmds, uiutil.ReportError(err)) - } else { - // TODO: Get. Rid. Of. Magic numbers! - commands.SetSize(min(120, m.width-8), 30) - m.dialog.OpenDialog(commands) - } + if cmd := m.openCommandsDialog(); cmd != nil { + cmds = append(cmds, cmd) } + return true case key.Matches(msg, m.keyMap.Models): // TODO: Implement me case key.Matches(msg, m.keyMap.Sessions): @@ -398,13 +359,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { // Bring to front m.dialog.BringToFront(dialog.SessionsID) } else { - cmds = append(cmds, m.loadSessionsCmd) + cmds = append(cmds, m.listSessions) } return true } return false } + // Route all messages to dialog if one is open. if m.dialog.HasDialogs() { // Always handle quit keys first if handleQuitKeys(msg) { @@ -420,19 +382,18 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { // Generic dialog messages case dialog.CloseMsg: m.dialog.CloseFrontDialog() + // Session dialog messages case dialog.SessionSelectedMsg: m.dialog.CloseDialog(dialog.SessionsID) - cmds = append(cmds, - m.loadSession(msg.Session.ID), - m.loadSessionFiles(msg.Session.ID), - ) + cmds = append(cmds, m.loadSession(msg.Session.ID)) + // Command dialog messages case dialog.ToggleYoloModeMsg: m.com.App.Permissions.SetSkipRequests(!m.com.App.Permissions.SkipRequests()) m.dialog.CloseDialog(dialog.CommandsID) case dialog.SwitchSessionsMsg: - cmds = append(cmds, m.loadSessionsCmd) + cmds = append(cmds, m.listSessions) m.dialog.CloseDialog(dialog.CommandsID) case dialog.CompactMsg: err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) @@ -1039,11 +1000,52 @@ func (m *UI) renderSidebarLogo(width int) { m.sidebarLogo = renderLogo(m.com.Styles, true, width) } -// loadSessionsCmd loads the list of sessions and returns a command that sends -// a sessionFilesLoadedMsg when done. -func (m *UI) loadSessionsCmd() tea.Msg { +// openCommandsDialog opens the commands dialog. +func (m *UI) openCommandsDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.CommandsID) { + // Bring to front + m.dialog.BringToFront(dialog.CommandsID) + return nil + } + + sessionID := "" + if m.session != nil { + sessionID = m.session.ID + } + + commands, err := dialog.NewCommands(m.com, sessionID) + if err != nil { + return uiutil.ReportError(err) + } + + // TODO: Get. Rid. Of. Magic numbers! + commands.SetSize(min(120, m.width-8), 30) + m.dialog.OpenDialog(commands) + + return nil +} + +// openSessionsDialog opens the sessions dialog with the given sessions. +func (m *UI) openSessionsDialog(sessions []session.Session) tea.Cmd { + if m.dialog.ContainsDialog(dialog.SessionsID) { + // Bring to front + m.dialog.BringToFront(dialog.SessionsID) + return nil + } + + dialog := dialog.NewSessions(m.com, sessions...) + // TODO: Get. Rid. Of. Magic numbers! + dialog.SetSize(min(120, m.width-8), 30) + m.dialog.OpenDialog(dialog) + + return nil +} + +// listSessions is a [tea.Cmd] that lists all sessions and returns them in a +// [listSessionsMsg]. +func (m *UI) listSessions() tea.Msg { allSessions, _ := m.com.App.Sessions.List(context.TODO()) - return sessionsLoadedMsg{sessions: allSessions} + return listSessionsMsg{sessions: allSessions} } // renderLogo renders the Crush logo with the given styles and dimensions. From 7b1914262610b73ed6873705402dee5760b32feb Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 16 Dec 2025 13:47:21 -0500 Subject: [PATCH 057/335] fix(ui): editor: show yolo prompt correctly --- internal/ui/model/ui.go | 15 ++++++++++----- internal/ui/styles/styles.go | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index bc8d1bb17ac9e1c651cf3f3aac150047ee16fa7c..52b6f80a71d02b2866fead4f296abccbd66b5ae1 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -151,7 +151,7 @@ func New(com *common.Common) *UI { ui.focus = uiFocusEditor } - ui.setEditorPrompt() + ui.setEditorPrompt(false) ui.randomizePlaceholders() ui.textarea.Placeholder = ui.readyPlaceholder ui.help.Styles = com.Styles.Help @@ -390,7 +390,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { // Command dialog messages case dialog.ToggleYoloModeMsg: - m.com.App.Permissions.SetSkipRequests(!m.com.App.Permissions.SkipRequests()) + yolo := !m.com.App.Permissions.SkipRequests() + m.com.App.Permissions.SetSkipRequests(yolo) + m.setEditorPrompt(yolo) m.dialog.CloseDialog(dialog.CommandsID) case dialog.SwitchSessionsMsg: cmds = append(cmds, m.listSessions) @@ -927,8 +929,8 @@ type layout struct { // setEditorPrompt configures the textarea prompt function based on whether // yolo mode is enabled. -func (m *UI) setEditorPrompt() { - if m.com.App.Permissions.SkipRequests() { +func (m *UI) setEditorPrompt(yolo bool) { + if yolo { m.textarea.SetPromptFunc(4, m.yoloPromptFunc) return } @@ -940,7 +942,10 @@ func (m *UI) setEditorPrompt() { func (m *UI) normalPromptFunc(info textarea.PromptInfo) string { t := m.com.Styles if info.LineNumber == 0 { - return " > " + if info.Focused { + return " > " + } + return "::: " } if info.Focused { return t.EditorPromptNormalFocused.Render() diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 7690bdb704de6338754d931373fcf29924efc6a8..c78cdcacd2b4a3636ae07fc2f75ccdf9fe984d9b 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -828,9 +828,9 @@ func DefaultStyles() Styles { // Editor s.EditorPromptNormalFocused = lipgloss.NewStyle().Foreground(greenDark).SetString("::: ") s.EditorPromptNormalBlurred = s.EditorPromptNormalFocused.Foreground(fgMuted) - s.EditorPromptYoloIconFocused = lipgloss.NewStyle().Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ") + s.EditorPromptYoloIconFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ") s.EditorPromptYoloIconBlurred = s.EditorPromptYoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid) - s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::") + s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Zest).SetString(":::") s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid) // Logo colors From 7b267bc6a68bd8de20e8bbea9c25024e63412731 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 16 Dec 2025 14:10:56 -0500 Subject: [PATCH 058/335] fix(ui): openEditor and handle pasted files in editor --- internal/ui/model/ui.go | 146 ++++++++++++++++++++++++++++++++-------- 1 file changed, 117 insertions(+), 29 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 52b6f80a71d02b2866fead4f296abccbd66b5ae1..767b34fc7214c3a5148a1a1916dbc486b8942662 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -4,7 +4,10 @@ import ( "context" "image" "math/rand" + "net/http" "os" + "path/filepath" + "runtime" "slices" "strings" @@ -20,6 +23,7 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" "github.com/charmbracelet/crush/internal/ui/logo" @@ -51,6 +55,10 @@ const ( uiChatCompact ) +type openEditorMsg struct { + Text string +} + // listSessionsMsg is a message to list available sessions. type listSessionsMsg struct { sessions []session.Session @@ -306,6 +314,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyPressMsg: cmds = append(cmds, m.handleKeyPressMsg(msg)...) + case tea.PasteMsg: + if cmd := m.handlePasteMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } + case openEditorMsg: + m.textarea.SetValue(msg.Text) + m.textarea.MoveToEnd() } // This logic gets triggered on any message type, but should it? @@ -413,7 +428,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { } switch m.state { - case uiChat: + case uiConfigure: + return cmds + case uiInitialize: + return append(cmds, m.updateInitializeView(msg)...) + case uiChat, uiLanding, uiChatCompact: switch m.focus { case uiFocusEditor: switch { @@ -422,8 +441,21 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { m.textarea.Blur() m.chat.Focus() m.chat.SetSelected(m.chat.Len() - 1) + case key.Matches(msg, m.keyMap.Editor.OpenEditor): + if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) { + cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait...")) + break + } + cmds = append(cmds, m.openEditor(m.textarea.Value())) default: - handleGlobalKeys(msg) + if handleGlobalKeys(msg) { + // Handle global keys first before passing to textarea. + break + } + + ta, cmd := m.textarea.Update(msg) + m.textarea = ta + cmds = append(cmds, cmd) } case uiFocusMain: switch { @@ -477,7 +509,6 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { handleGlobalKeys(msg) } - cmds = append(cmds, m.updateFocused(msg)...) return cmds } @@ -724,32 +755,6 @@ func (m *UI) FullHelp() [][]key.Binding { return binds } -// updateFocused updates the focused model (chat or editor) with the given message -// and appends any resulting commands to the cmds slice. -func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) { - switch m.state { - case uiConfigure: - return cmds - case uiInitialize: - return append(cmds, m.updateInitializeView(msg)...) - case uiChat, uiLanding, uiChatCompact: - switch m.focus { - case uiFocusMain: - case uiFocusEditor: - switch { - case key.Matches(msg, m.keyMap.Editor.Newline): - m.textarea.InsertRune('\n') - } - - ta, cmd := m.textarea.Update(msg) - m.textarea = ta - cmds = append(cmds, cmd) - return cmds - } - } - return cmds -} - // updateLayoutAndSize updates the layout and sizes of UI components. func (m *UI) updateLayoutAndSize() { m.layout = generateLayout(m, m.width, m.height) @@ -927,6 +932,44 @@ type layout struct { help uv.Rectangle } +func (m *UI) openEditor(value string) tea.Cmd { + editor := os.Getenv("EDITOR") + if editor == "" { + // Use platform-appropriate default editor + if runtime.GOOS == "windows" { + editor = "notepad" + } else { + editor = "nvim" + } + } + + tmpfile, err := os.CreateTemp("", "msg_*.md") + if err != nil { + return uiutil.ReportError(err) + } + defer tmpfile.Close() //nolint:errcheck + if _, err := tmpfile.WriteString(value); err != nil { + return uiutil.ReportError(err) + } + cmdStr := editor + " " + tmpfile.Name() + return uiutil.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg { + if err != nil { + return uiutil.ReportError(err) + } + content, err := os.ReadFile(tmpfile.Name()) + if err != nil { + return uiutil.ReportError(err) + } + if len(content) == 0 { + return uiutil.ReportWarn("Message is empty") + } + os.Remove(tmpfile.Name()) + return openEditorMsg{ + Text: strings.TrimSpace(string(content)), + } + }) +} + // setEditorPrompt configures the textarea prompt function based on whether // yolo mode is enabled. func (m *UI) setEditorPrompt(yolo bool) { @@ -1053,6 +1096,51 @@ func (m *UI) listSessions() tea.Msg { return listSessionsMsg{sessions: allSessions} } +// handlePasteMsg handles a paste message. +func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { + if m.focus != uiFocusEditor { + return nil + } + + var cmd tea.Cmd + path := strings.ReplaceAll(msg.Content, "\\ ", " ") + // try to get an image + path, err := filepath.Abs(strings.TrimSpace(path)) + if err != nil { + m.textarea, cmd = m.textarea.Update(msg) + return cmd + } + isAllowedType := false + for _, ext := range filepicker.AllowedTypes { + if strings.HasSuffix(path, ext) { + isAllowedType = true + break + } + } + if !isAllowedType { + m.textarea, cmd = m.textarea.Update(msg) + return cmd + } + tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize) + if tooBig { + m.textarea, cmd = m.textarea.Update(msg) + return cmd + } + + content, err := os.ReadFile(path) + if err != nil { + m.textarea, cmd = m.textarea.Update(msg) + return cmd + } + 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 uiutil.CmdHandler(filepicker.FilePickedMsg{ + Attachment: attachment, + }) +} + // 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{ From 1bb5aa8e0784f68313c07d54188a083b5d009121 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 16 Dec 2025 15:59:06 -0500 Subject: [PATCH 059/335] fix(ui): improve key handling and keybindings for chat/editor --- internal/ui/model/keys.go | 50 +++++++---- internal/ui/model/ui.go | 178 ++++++++++++++++++++++++++++---------- 2 files changed, 167 insertions(+), 61 deletions(-) diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index d146e53853e7a9d6dd234e8b911a636b16e8a170..d421c00ca032a97b424fafe6442a243fc98080b1 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -18,21 +18,25 @@ type KeyMap struct { } Chat struct { - NewSession key.Binding - AddAttachment key.Binding - Cancel key.Binding - Tab key.Binding - Details key.Binding - Down key.Binding - Up key.Binding - DownOneItem key.Binding - UpOneItem key.Binding - PageDown key.Binding - PageUp key.Binding - HalfPageDown key.Binding - HalfPageUp key.Binding - Home key.Binding - End key.Binding + NewSession key.Binding + AddAttachment key.Binding + Cancel key.Binding + Tab key.Binding + Details key.Binding + Down key.Binding + Up key.Binding + UpDown key.Binding + DownOneItem key.Binding + UpOneItem key.Binding + UpDownOneItem key.Binding + PageDown key.Binding + PageUp key.Binding + HalfPageDown key.Binding + HalfPageUp key.Binding + Home key.Binding + End key.Binding + Copy key.Binding + ClearHighlight key.Binding } Initialize struct { @@ -153,6 +157,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("up", "ctrl+k", "ctrl+p", "k"), key.WithHelp("↑", "up"), ) + km.Chat.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑↓", "scroll"), + ) km.Chat.UpOneItem = key.NewBinding( key.WithKeys("shift+up", "K"), key.WithHelp("shift+↑", "up one item"), @@ -161,6 +169,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("shift+down", "J"), key.WithHelp("shift+↓", "down one item"), ) + km.Chat.UpDownOneItem = key.NewBinding( + key.WithKeys("shift+up", "shift+down"), + key.WithHelp("shift+↑↓", "scroll one item"), + ) km.Chat.HalfPageDown = key.NewBinding( key.WithKeys("d"), key.WithHelp("d", "half page down"), @@ -185,6 +197,14 @@ func DefaultKeyMap() KeyMap { key.WithKeys("G", "end"), key.WithHelp("G", "end"), ) + km.Chat.Copy = key.NewBinding( + key.WithKeys("c", "y", "C", "Y"), + key.WithHelp("c/y", "copy"), + ) + km.Chat.ClearHighlight = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "clear selection"), + ) km.Initialize.Yes = key.NewBinding( key.WithKeys("y", "Y"), diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 767b34fc7214c3a5148a1a1916dbc486b8942662..64eb0618cc6702dafc175ee7b271d6f31e24b85a 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -313,7 +313,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case tea.KeyPressMsg: - cmds = append(cmds, m.handleKeyPressMsg(msg)...) + if cmd := m.handleKeyPressMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } case tea.PasteMsg: if cmd := m.handlePasteMsg(msg); cmd != nil { cmds = append(cmds, cmd) @@ -341,7 +343,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { +func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { + var cmds []tea.Cmd + handleQuitKeys := func(msg tea.KeyPressMsg) bool { switch { case key.Matches(msg, m.keyMap.Quit): @@ -369,6 +373,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { return true case key.Matches(msg, m.keyMap.Models): // TODO: Implement me + return true case key.Matches(msg, m.keyMap.Sessions): if m.dialog.ContainsDialog(dialog.SessionsID) { // Bring to front @@ -385,12 +390,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { if m.dialog.HasDialogs() { // Always handle quit keys first if handleQuitKeys(msg) { - return cmds + return tea.Batch(cmds...) } msg := m.dialog.Update(msg) if msg == nil { - return cmds + return tea.Batch(cmds...) } switch msg := msg.(type) { @@ -424,18 +429,21 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { cmds = append(cmds, tea.Quit) } - return cmds + return tea.Batch(cmds...) } switch m.state { case uiConfigure: - return cmds + return tea.Batch(cmds...) case uiInitialize: - return append(cmds, m.updateInitializeView(msg)...) + cmds = append(cmds, m.updateInitializeView(msg)...) + return tea.Batch(cmds...) case uiChat, uiLanding, uiChatCompact: switch m.focus { case uiFocusEditor: switch { + case key.Matches(msg, m.keyMap.Editor.SendMessage): + // TODO: Implement me case key.Matches(msg, m.keyMap.Tab): m.focus = uiFocusMain m.textarea.Blur() @@ -447,6 +455,8 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { break } cmds = append(cmds, m.openEditor(m.textarea.Value())) + case key.Matches(msg, m.keyMap.Editor.Newline): + m.textarea.InsertRune('\n') default: if handleGlobalKeys(msg) { // Handle global keys first before passing to textarea. @@ -509,12 +519,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { handleGlobalKeys(msg) } - return cmds + return tea.Batch(cmds...) } // Draw implements [tea.Layer] and draws the UI model. func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { - layout := generateLayout(m, area.Dx(), area.Dy()) + layout := m.generateLayout(area.Dx(), area.Dy()) if m.layout != layout { m.layout = layout @@ -665,46 +675,58 @@ func (m *UI) View() tea.View { func (m *UI) ShortHelp() []key.Binding { var binds []key.Binding k := &m.keyMap + tab := k.Tab + commands := k.Commands + if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 { + commands.SetHelp("/ or ctrl+p", "commands") + } switch m.state { case uiInitialize: binds = append(binds, k.Quit) + case uiChat: + if m.focus == uiFocusEditor { + tab.SetHelp("tab", "focus chat") + } else { + tab.SetHelp("tab", "focus editor") + } + + binds = append(binds, + tab, + commands, + k.Models, + ) + + switch m.focus { + case uiFocusEditor: + binds = append(binds, + k.Editor.Newline, + ) + case uiFocusMain: + binds = append(binds, + k.Chat.UpDown, + k.Chat.UpDownOneItem, + k.Chat.PageUp, + k.Chat.PageDown, + k.Chat.Copy, + ) + } default: // TODO: other states // if m.session == nil { // no session selected binds = append(binds, - k.Commands, + commands, k.Models, k.Editor.Newline, - k.Quit, - k.Help, ) - // } - // else { - // we have a session - // } - - // switch m.state { - // case uiChat: - // case uiEdit: - // binds = append(binds, - // k.Editor.AddFile, - // k.Editor.SendMessage, - // k.Editor.OpenEditor, - // k.Editor.Newline, - // ) - // - // if len(m.attachments) > 0 { - // binds = append(binds, - // k.Editor.AttachmentDeleteMode, - // k.Editor.DeleteAllAttachments, - // k.Editor.Escape, - // ) - // } - // } } + binds = append(binds, + k.Quit, + k.Help, + ) + return binds } @@ -714,6 +736,12 @@ func (m *UI) FullHelp() [][]key.Binding { k := &m.keyMap help := k.Help help.SetHelp("ctrl+g", "less") + hasAttachments := false // TODO: implement attachments + hasSession := m.session != nil && m.session.ID != "" + commands := k.Commands + if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 { + commands.SetHelp("/ or ctrl+p", "commands") + } switch m.state { case uiInitialize: @@ -721,12 +749,72 @@ func (m *UI) FullHelp() [][]key.Binding { []key.Binding{ k.Quit, }) + case uiChat: + mainBinds := []key.Binding{} + tab := k.Tab + if m.focus == uiFocusEditor { + tab.SetHelp("tab", "focus chat") + } else { + tab.SetHelp("tab", "focus editor") + } + + mainBinds = append(mainBinds, + tab, + commands, + k.Models, + k.Sessions, + ) + if hasSession { + mainBinds = append(mainBinds, k.Chat.NewSession) + } + + binds = append(binds, mainBinds) + + switch m.focus { + case uiFocusEditor: + binds = append(binds, + []key.Binding{ + k.Editor.Newline, + k.Editor.AddImage, + k.Editor.MentionFile, + k.Editor.OpenEditor, + }, + ) + if hasAttachments { + binds = append(binds, + []key.Binding{ + k.Editor.AttachmentDeleteMode, + k.Editor.DeleteAllAttachments, + k.Editor.Escape, + }, + ) + } + case uiFocusMain: + binds = append(binds, + []key.Binding{ + k.Chat.UpDown, + k.Chat.UpDownOneItem, + k.Chat.PageUp, + k.Chat.PageDown, + }, + []key.Binding{ + k.Chat.HalfPageUp, + k.Chat.HalfPageDown, + k.Chat.Home, + k.Chat.End, + }, + []key.Binding{ + k.Chat.Copy, + k.Chat.ClearHighlight, + }, + ) + } default: if m.session == nil { // no session selected binds = append(binds, []key.Binding{ - k.Commands, + commands, k.Models, k.Sessions, }, @@ -741,23 +829,21 @@ func (m *UI) FullHelp() [][]key.Binding { }, ) } - // else { - // we have a session - // } } - // switch m.state { - // case uiChat: - // case uiEdit: - // binds = append(binds, m.ShortHelp()) - // } + binds = append(binds, + []key.Binding{ + help, + k.Quit, + }, + ) return binds } // updateLayoutAndSize updates the layout and sizes of UI components. func (m *UI) updateLayoutAndSize() { - m.layout = generateLayout(m, m.width, m.height) + m.layout = m.generateLayout(m.width, m.height) m.updateSize() } @@ -786,7 +872,7 @@ func (m *UI) updateSize() { // generateLayout calculates the layout rectangles for all UI components based // on the current UI state and terminal dimensions. -func generateLayout(m *UI, w, h int) layout { +func (m *UI) generateLayout(w, h int) layout { // The screen area we're working with area := image.Rect(0, 0, w, h) From cd81f945f2a19d0af08c6d9b6db646da3781f19b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 16 Dec 2025 16:39:25 -0500 Subject: [PATCH 060/335] feat(ui): wip: basic chat message sending --- internal/ui/chat/messages.go | 9 ++ internal/ui/dialog/commands.go | 11 +-- internal/ui/dialog/quit.go | 10 ++- internal/ui/model/ui.go | 154 +++++++++++++++++++++++++-------- 4 files changed, 137 insertions(+), 47 deletions(-) create mode 100644 internal/ui/chat/messages.go diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go new file mode 100644 index 0000000000000000000000000000000000000000..1bae62e2b2301bdabbbdb68828705f1360ccd49f --- /dev/null +++ b/internal/ui/chat/messages.go @@ -0,0 +1,9 @@ +package chat + +import "github.com/charmbracelet/crush/internal/message" + +// SendMsg represents a message to send a chat message. +type SendMsg struct { + Text string + Attachments []message.Attachment +} diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index d6fbfa750f27e7b6641b543d417a5b4aa07c0bbc..752b465c6005f384065a9df1d47df00ed14eba78 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -15,7 +15,7 @@ import ( "github.com/charmbracelet/crush/internal/agent" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" - "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" @@ -26,13 +26,6 @@ import ( // CommandsID is the identifier for the commands dialog. const CommandsID = "commands" -// SendMsg represents a message to send a chat message. -// TODO: Move to chat package? -type SendMsg struct { - Text string - Attachments []message.Attachment -} - // Commands represents a dialog that shows available commands. type Commands struct { com *common.Common @@ -451,7 +444,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { if err != nil { return uiutil.ReportError(err) } - return uiutil.CmdHandler(SendMsg{ + return uiutil.CmdHandler(chat.SendMsg{ Text: initPrompt, }) }, diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go index 8f687571c8789901ac7cadc464cc4aecf53698db..21ed6a5128f6fee85a2a3216ea303fa8d843258a 100644 --- a/internal/ui/dialog/quit.go +++ b/internal/ui/dialog/quit.go @@ -20,7 +20,8 @@ type Quit struct { Yes, No, Tab, - Close key.Binding + Close, + Quit key.Binding } } @@ -51,6 +52,10 @@ func NewQuit(com *common.Common) *Quit { key.WithHelp("tab", "switch options"), ) q.keyMap.Close = CloseKey + q.keyMap.Quit = key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ) return q } @@ -64,11 +69,12 @@ func (q *Quit) Update(msg tea.Msg) tea.Msg { switch msg := msg.(type) { case tea.KeyPressMsg: switch { + case key.Matches(msg, q.keyMap.Quit): + return QuitMsg{} case key.Matches(msg, q.keyMap.Close): return CloseMsg{} case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab): q.selectedNo = !q.selectedNo - return CloseMsg{} case key.Matches(msg, q.keyMap.EnterSpace): if !q.selectedNo { return QuitMsg{} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 64eb0618cc6702dafc175ee7b271d6f31e24b85a..2c0ab9e85c1b57803c8424bb29eb5a5c0774357e 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2,6 +2,8 @@ package model import ( "context" + "errors" + "fmt" "image" "math/rand" "net/http" @@ -21,9 +23,12 @@ import ( "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/chat" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" + "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" "github.com/charmbracelet/crush/internal/ui/logo" @@ -98,7 +103,7 @@ type UI struct { // Editor components textarea textarea.Model - attachments []any // TODO: Implement attachments + attachments []message.Attachment // TODO: Implement attachments readyPlaceholder string workingPlaceholder string @@ -199,23 +204,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } - // Build tool result map to link tool calls with their results - msgPtrs := make([]*message.Message, len(msgs)) - for i := range msgs { - msgPtrs[i] = &msgs[i] - } - toolResultMap := BuildToolResultMap(msgPtrs) - - // Add messages to chat with linked tool results - items := make([]MessageItem, 0, len(msgs)*2) - for _, msg := range msgPtrs { - items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...) + if cmd := m.handleMessageEvents(msgs...); cmd != nil { + cmds = append(cmds, cmd) } - - m.chat.SetMessages(items...) - - m.chat.ScrollToBottom() - m.chat.SelectLast() + case pubsub.Event[message.Message]: + // TODO: Finish implementing me + cmds = append(cmds, m.handleMessageEvents(msg.Payload)) case pubsub.Event[history.File]: cmds = append(cmds, m.handleFileEvent(msg.Payload)) case pubsub.Event[app.LSPEvent]: @@ -343,24 +337,35 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { - var cmds []tea.Cmd +func (m *UI) handleMessageEvents(msgs ...message.Message) tea.Cmd { + // Build tool result map to link tool calls with their results + msgPtrs := make([]*message.Message, len(msgs)) + for i := range msgs { + msgPtrs[i] = &msgs[i] + } + toolResultMap := BuildToolResultMap(msgPtrs) - handleQuitKeys := func(msg tea.KeyPressMsg) bool { - switch { - case key.Matches(msg, m.keyMap.Quit): - if !m.dialog.ContainsDialog(dialog.QuitID) { - m.dialog.OpenDialog(dialog.NewQuit(m.com)) - return true - } - } - return false + // Add messages to chat with linked tool results + items := make([]MessageItem, 0, len(msgs)*2) + for _, msg := range msgPtrs { + items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...) + } + + if m.session == nil || m.session.ID == "" { + m.chat.SetMessages(items...) + } else { + m.chat.AppendMessages(items...) } + m.chat.ScrollToBottom() + m.chat.SelectLast() + + return nil +} + +func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { + var cmds []tea.Cmd handleGlobalKeys := func(msg tea.KeyPressMsg) bool { - if handleQuitKeys(msg) { - return true - } switch { case key.Matches(msg, m.keyMap.Help): m.help.ShowAll = !m.help.ShowAll @@ -386,13 +391,17 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { return false } - // Route all messages to dialog if one is open. - if m.dialog.HasDialogs() { + if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) { // Always handle quit keys first - if handleQuitKeys(msg) { - return tea.Batch(cmds...) + if cmd := m.openQuitDialog(); cmd != nil { + cmds = append(cmds, cmd) } + return tea.Batch(cmds...) + } + + // Route all messages to dialog if one is open. + if m.dialog.HasDialogs() { msg := m.dialog.Update(msg) if msg == nil { return tea.Batch(cmds...) @@ -443,7 +452,30 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { case uiFocusEditor: switch { case key.Matches(msg, m.keyMap.Editor.SendMessage): - // TODO: Implement me + 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, "\\")) + break + } + + // Otherwise, send the message + m.textarea.Reset() + + value = strings.TrimSpace(value) + if value == "exit" || value == "quit" { + return m.openQuitDialog() + } + + attachments := m.attachments + m.attachments = nil + if len(value) == 0 { + return nil + } + + m.randomizePlaceholders() + + return m.sendMessage(value, attachments) case key.Matches(msg, m.keyMap.Tab): m.focus = uiFocusMain m.textarea.Blur() @@ -1134,6 +1166,56 @@ func (m *UI) renderSidebarLogo(width int) { m.sidebarLogo = renderLogo(m.com.Styles, true, width) } +// sendMessage sends a message with the given content and attachments. +func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd { + if m.session == nil { + return uiutil.ReportError(fmt.Errorf("no session selected")) + } + session := *m.session + var cmds []tea.Cmd + if m.session.ID == "" { + newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session") + if err != nil { + return uiutil.ReportError(err) + } + session = newSession + cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session))) + } + if m.com.App.AgentCoordinator == nil { + return util.ReportError(fmt.Errorf("coder agent is not initialized")) + } + m.chat.ScrollToBottom() + cmds = append(cmds, func() tea.Msg { + _, err := m.com.App.AgentCoordinator.Run(context.Background(), session.ID, content, 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...) +} + +// openQuitDialog opens the quit confirmation dialog. +func (m *UI) openQuitDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.QuitID) { + // Bring to front + m.dialog.BringToFront(dialog.QuitID) + return nil + } + + quitDialog := dialog.NewQuit(m.com) + m.dialog.OpenDialog(quitDialog) + return nil +} + // openCommandsDialog opens the commands dialog. func (m *UI) openCommandsDialog() tea.Cmd { if m.dialog.ContainsDialog(dialog.CommandsID) { From d54a5e7eb11892ecb67e3f8510028d037c4978f9 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 16 Dec 2025 16:47:36 -0500 Subject: [PATCH 061/335] fix(ui): dialog: align radio buttons with checkboxes --- internal/ui/dialog/commands.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 752b465c6005f384065a9df1d47df00ed14eba78..3e275081691acec9a7538f80da8eeea87a662fd7 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -190,9 +190,9 @@ func radioView(t *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, h selectedFn := func(t uicmd.CommandType) string { if t == selected { - return "◉ " + t.String() + return " ◉ " + t.String() } - return "○ " + t.String() + return " ○ " + t.String() } parts := []string{ From 517a3613c12513d91d93b59c56aae5204c12b4e6 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 16 Dec 2025 16:51:39 -0500 Subject: [PATCH 062/335] feat(ui): dialog: wrap navigation from last to first and vice versa --- internal/ui/dialog/commands.go | 10 ++++++++++ internal/ui/dialog/sessions.go | 10 ++++++++++ internal/ui/list/list.go | 16 ++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 3e275081691acec9a7538f80da8eeea87a662fd7..763e20969df1c62849a4ee4907c95132f9c01ddb 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -133,10 +133,20 @@ func (c *Commands) Update(msg tea.Msg) tea.Msg { return CloseMsg{} case key.Matches(msg, c.keyMap.Previous): c.list.Focus() + if c.list.IsSelectedFirst() { + c.list.SelectLast() + c.list.ScrollToBottom() + break + } c.list.SelectPrev() c.list.ScrollToSelected() case key.Matches(msg, c.keyMap.Next): c.list.Focus() + if c.list.IsSelectedLast() { + c.list.SelectFirst() + c.list.ScrollToTop() + break + } c.list.SelectNext() c.list.ScrollToSelected() case key.Matches(msg, c.keyMap.Select): diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 016bd1a8f79373296ecbec7acf2e36b97dae8ff5..933547c5665b292f9cb7a125424803f219bbf706 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -89,10 +89,20 @@ func (s *Session) Update(msg tea.Msg) tea.Msg { return CloseMsg{} case key.Matches(msg, s.keyMap.Previous): s.list.Focus() + if s.list.IsSelectedFirst() { + s.list.SelectLast() + s.list.ScrollToBottom() + break + } s.list.SelectPrev() s.list.ScrollToSelected() case key.Matches(msg, s.keyMap.Next): s.list.Focus() + if s.list.IsSelectedLast() { + s.list.SelectFirst() + s.list.ScrollToTop() + break + } s.list.SelectNext() s.list.ScrollToSelected() case key.Matches(msg, s.keyMap.Select): diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 07414882400795eaa1e08fbab19c22d37d98ffa5..17766cd52506132322c051fffaf33de613332315 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -398,6 +398,22 @@ func (l *List) SetSelected(index int) { } } +// Selected returns the index of the currently selected item. It returns -1 if +// no item is selected. +func (l *List) Selected() int { + return l.selectedIdx +} + +// IsSelectedFirst returns whether the first item is selected. +func (l *List) IsSelectedFirst() bool { + return l.selectedIdx == 0 +} + +// IsSelectedLast returns whether the last item is selected. +func (l *List) IsSelectedLast() bool { + return l.selectedIdx == len(l.items)-1 +} + // SelectPrev selects the previous item in the list. func (l *List) SelectPrev() { if l.selectedIdx > 0 { From 675d8515bfa37db9dd270f6d97faa494c650af2f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 16 Dec 2025 17:12:07 -0500 Subject: [PATCH 063/335] fix(ui): adjust dialog sizing to account for dynamic title and help heights --- internal/ui/dialog/commands.go | 9 +++++++-- internal/ui/dialog/sessions.go | 11 ++++++++--- internal/ui/dialog/sessions_item.go | 10 +++++++--- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 763e20969df1c62849a4ee4907c95132f9c01ddb..1d45064a1a716ae1a6736a27a15c6c358b2e108e 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -111,11 +111,16 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) { // SetSize sets the size of the dialog. func (c *Commands) SetSize(width, height int) { + t := c.com.Styles c.width = width c.height = height innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize() - c.input.SetWidth(innerWidth - c.com.Styles.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) - c.list.SetSize(innerWidth, height-6) // (1) title + (3) input + (1) padding + (1) help + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content + t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content + t.Dialog.HelpView.GetVerticalFrameSize() + + t.Dialog.View.GetVerticalFrameSize() + c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + c.list.SetSize(innerWidth, height-heightOffset) c.help.SetWidth(width) } diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 933547c5665b292f9cb7a125424803f219bbf706..bfc107ab40225aa33ad8f3520ef818020ac90f4a 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -67,11 +67,16 @@ func NewSessions(com *common.Common, sessions ...session.Session) *Session { // SetSize sets the size of the dialog. func (s *Session) SetSize(width, height int) { + t := s.com.Styles s.width = width s.height = height - innerWidth := width - s.com.Styles.Dialog.View.GetHorizontalFrameSize() - s.input.SetWidth(innerWidth - s.com.Styles.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) - s.list.SetSize(innerWidth, height-6) // (1) title + (3) input + (1) padding + (1) help + innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content + t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content + t.Dialog.HelpView.GetVerticalFrameSize() + + t.Dialog.View.GetVerticalFrameSize() + s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + s.list.SetSize(innerWidth, height-heightOffset) s.help.SetWidth(width) } diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index dbfd939c93530dad95e2f25bce543d9a33f39f5e..ddd46c94be32ba885963d17f74efd4d8759a96c1 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -84,11 +84,15 @@ func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, w age = " " + age } - ageLen := lipgloss.Width(age) - titleLen := lipgloss.Width(title) + + var ageLen int + if updatedAt > 0 { + ageLen = lipgloss.Width(age) + } + title = ansi.Truncate(title, max(0, width-ageLen), "…") + titleLen := lipgloss.Width(title) right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age) - content := title if matches := len(m.MatchedIndexes); matches > 0 { var lastPos int From 4f07a5485d11fd11e7216954bebd85cfcd1d4f0c Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 16:40:09 +0100 Subject: [PATCH 064/335] refactor(chat): implement user message (#1644) --- CRUSH.md => AGENTS.md | 3 + cspell.json | 2 +- internal/ui/AGENTS.md | 17 + internal/ui/chat/messages.go | 140 ++++++- internal/ui/chat/user.go | 122 +++++++ internal/ui/dialog/sessions_item.go | 4 +- internal/ui/list/highlight.go | 8 + internal/ui/model/chat.go | 5 +- internal/ui/model/items.go | 548 ---------------------------- internal/ui/model/ui.go | 27 +- 10 files changed, 305 insertions(+), 571 deletions(-) rename CRUSH.md => AGENTS.md (96%) create mode 100644 internal/ui/AGENTS.md create mode 100644 internal/ui/chat/user.go delete mode 100644 internal/ui/model/items.go diff --git a/CRUSH.md b/AGENTS.md similarity index 96% rename from CRUSH.md rename to AGENTS.md index b98b8813fde6109bd5fdbbc21b1c2f92dee602af..7fab72afb836136020500b7f27e905f3dcfc72da 100644 --- a/CRUSH.md +++ b/AGENTS.md @@ -70,3 +70,6 @@ func TestYourFunction(t *testing.T) { - ALWAYS use semantic commits (`fix:`, `feat:`, `chore:`, `refactor:`, `docs:`, `sec:`, etc). - Try to keep commits to one line, not including your attribution. Only use multi-line commits when additional context is truly necessary. + +## Working on the TUI (UI) +Anytime you need to work on the tui before starting work read the internal/ui/AGENTS.md file diff --git a/cspell.json b/cspell.json index 713684deb4cf3f066d92b6a71a063df90cddf0fc..c0faed860420b452c9f22592a27a327b8895f2fc 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"flagWords":[],"version":"0.2","language":"en","words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync"]} \ No newline at end of file +{"version":"0.2","language":"en","flagWords":[],"words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync","Highlightable","Highlightable","prerendered","prerender","kujtim"]} \ No newline at end of file diff --git a/internal/ui/AGENTS.md b/internal/ui/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..fef22d3df835f38efb2265c0021ff1beefed5714 --- /dev/null +++ b/internal/ui/AGENTS.md @@ -0,0 +1,17 @@ +# UI Development Instructions + +## General guideline +- Never use commands to send messages when you can directly mutate children or state +- Keep things simple do not overcomplicated +- Create files if needed to separate logic do not nest models + +## Big model +Keep most of the logic and state in the main model `internal/ui/model/ui.go`. + + +## When working on components +Whenever you work on components make them dumb they should not handle bubble tea messages they should have methods. + +## When adding logic that has to do with the chat +Most of the logic with the chat should be in the chat component `internal/ui/model/chat.go`, keep individual items dumb and handle logic in this component. + diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 1bae62e2b2301bdabbbdb68828705f1360ccd49f..5fd52ff854e15fc7170bb58175196ad3aeb47306 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -1,9 +1,147 @@ package chat -import "github.com/charmbracelet/crush/internal/message" +import ( + "image" + + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// this is the total width that is taken up by the border + padding +// we also cap the width so text is readable to the maxTextWidth(120) +const messageLeftPaddingTotal = 2 + +// maxTextWidth is the maximum width text messages can be +const maxTextWidth = 120 + +// Identifiable is an interface for items that can provide a unique identifier. +type Identifiable interface { + ID() string +} + +// MessageItem represents a [message.Message] item that can be displayed in the +// UI and be part of a [list.List] identifiable by a unique ID. +type MessageItem interface { + list.Item + list.Highlightable + list.Focusable + Identifiable +} // SendMsg represents a message to send a chat message. type SendMsg struct { Text string Attachments []message.Attachment } + +type highlightableMessageItem struct { + startLine int + startCol int + endLine int + endCol int + highlighter list.Highlighter +} + +// isHighlighted returns true if the item has a highlight range set. +func (h *highlightableMessageItem) isHighlighted() bool { + return h.startLine != -1 || h.endLine != -1 +} + +// renderHighlighted highlights the content if necessary. +func (h *highlightableMessageItem) renderHighlighted(content string, width, height int) string { + if !h.isHighlighted() { + return content + } + area := image.Rect(0, 0, width, height) + return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter) +} + +// Highlight implements MessageItem. +func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLine int, endCol int) { + // Adjust columns for the style's left inset (border + padding) since we + // highlight the content only. + offset := messageLeftPaddingTotal + h.startLine = startLine + h.startCol = max(0, startCol-offset) + h.endLine = endLine + if endCol >= 0 { + h.endCol = max(0, endCol-offset) + } else { + h.endCol = endCol + } +} + +func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem { + return &highlightableMessageItem{ + startLine: -1, + startCol: -1, + endLine: -1, + endCol: -1, + highlighter: list.ToHighlighter(sty.TextSelection), + } +} + +// cachedMessageItem caches rendered message content to avoid re-rendering. +// +// This should be used by any message that can store a cahced version of its render. e.x user,assistant... and so on +// +// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths +// the issue with that could be memory usage +type cachedMessageItem struct { + // rendered is the cached rendered string + rendered string + // width and height are the dimensions of the cached render + width int + height int +} + +// getCachedRender returns the cached render if it exists for the given width. +func (c *cachedMessageItem) getCachedRender(width int) (string, int, bool) { + if c.width == width && c.rendered != "" { + return c.rendered, c.height, true + } + return "", 0, false +} + +// setCachedRender sets the cached render. +func (c *cachedMessageItem) setCachedRender(rendered string, width, height int) { + c.rendered = rendered + c.width = width + c.height = height +} + +// cappedMessageWidth returns the maximum width for message content for readability. +func cappedMessageWidth(availableWidth int) int { + return min(availableWidth-messageLeftPaddingTotal, maxTextWidth) +} + +// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns +// all parts of the message as [MessageItem]s. +// +// For assistant messages with tool calls, pass a toolResults map to link results. +// Use BuildToolResultMap to create this map from all messages in a session. +func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { + switch msg.Role { + case message.User: + return []MessageItem{NewUserMessageItem(sty, msg)} + } + return []MessageItem{} +} + +// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages. +// Tool result messages (role == message.Tool) contain the results that should be linked +// to tool calls in assistant messages. +func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult { + resultMap := make(map[string]message.ToolResult) + for _, msg := range messages { + if msg.Role == message.Tool { + for _, result := range msg.ToolResults() { + if result.ToolCallID != "" { + resultMap[result.ToolCallID] = result + } + } + } + } + return resultMap +} diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go new file mode 100644 index 0000000000000000000000000000000000000000..b3e1bebb16b1bfebe9036b189ca0cfb42c234805 --- /dev/null +++ b/internal/ui/chat/user.go @@ -0,0 +1,122 @@ +package chat + +import ( + "fmt" + "path/filepath" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// UserMessageItem represents a user message in the chat UI. +type UserMessageItem struct { + *highlightableMessageItem + *cachedMessageItem + message *message.Message + sty *styles.Styles + focused bool +} + +// NewUserMessageItem creates a new UserMessageItem. +func NewUserMessageItem(sty *styles.Styles, message *message.Message) MessageItem { + return &UserMessageItem{ + highlightableMessageItem: defaultHighlighter(sty), + cachedMessageItem: &cachedMessageItem{}, + message: message, + sty: sty, + focused: false, + } +} + +// Render implements MessageItem. +func (m *UserMessageItem) Render(width int) string { + cappedWidth := cappedMessageWidth(width) + + style := m.sty.Chat.Message.UserBlurred + if m.focused { + style = m.sty.Chat.Message.UserFocused + } + + content, height, ok := m.getCachedRender(cappedWidth) + // cache hit + if ok { + return style.Render(m.renderHighlighted(content, cappedWidth, height)) + } + + renderer := common.MarkdownRenderer(m.sty, cappedWidth) + + msgContent := strings.TrimSpace(m.message.Content().Text) + result, err := renderer.Render(msgContent) + if err != nil { + content = msgContent + } else { + content = strings.TrimSuffix(result, "\n") + } + + if len(m.message.BinaryContent()) > 0 { + attachmentsStr := m.renderAttachments(cappedWidth) + content = strings.Join([]string{content, "", attachmentsStr}, "\n") + } + + height = lipgloss.Height(content) + m.setCachedRender(content, cappedWidth, height) + return style.Render(m.renderHighlighted(content, cappedWidth, height)) +} + +// SetFocused implements MessageItem. +func (m *UserMessageItem) SetFocused(focused bool) { + m.focused = focused +} + +// ID implements MessageItem. +func (m *UserMessageItem) ID() string { + return m.message.ID +} + +// renderAttachments renders attachments with wrapping if they exceed the width. +// TODO: change the styles here so they match the new design +func (m *UserMessageItem) renderAttachments(width int) string { + const maxFilenameWidth = 10 + + attachments := make([]string, len(m.message.BinaryContent())) + for i, attachment := range m.message.BinaryContent() { + filename := filepath.Base(attachment.Path) + attachments[i] = m.sty.Chat.Message.Attachment.Render(fmt.Sprintf( + " %s %s ", + styles.DocumentIcon, + ansi.Truncate(filename, maxFilenameWidth, "…"), + )) + } + + // Wrap attachments into lines that fit within the width. + var lines []string + var currentLine []string + currentWidth := 0 + + for _, att := range attachments { + attWidth := lipgloss.Width(att) + sepWidth := 1 + if len(currentLine) == 0 { + sepWidth = 0 + } + + if currentWidth+sepWidth+attWidth > width && len(currentLine) > 0 { + lines = append(lines, strings.Join(currentLine, " ")) + currentLine = []string{att} + currentWidth = attWidth + } else { + currentLine = append(currentLine, att) + currentWidth += sepWidth + attWidth + } + } + + if len(currentLine) > 0 { + lines = append(lines, strings.Join(currentLine, " ")) + } + + return strings.Join(lines, "\n") +} diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index ddd46c94be32ba885963d17f74efd4d8759a96c1..080de32dba34a8fa50f2b40db2d8335fdcb911d9 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -37,7 +37,7 @@ var _ ListItem = &SessionItem{} // Filter returns the filterable value of the session. func (s *SessionItem) Filter() string { - return s.Session.Title + return s.Title } // ID returns the unique identifier of the session. @@ -53,7 +53,7 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) { // Render returns the string representation of the session item. func (s *SessionItem) Render(width int) string { - return renderItem(s.t, s.Session.Title, s.Session.UpdatedAt, s.focused, width, s.cache, &s.m) + return renderItem(s.t, s.Title, s.UpdatedAt, s.focused, width, s.cache, &s.m) } func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, width int, cache map[int]string, m *fuzzy.Match) string { diff --git a/internal/ui/list/highlight.go b/internal/ui/list/highlight.go index a1454a7edafb3cd623b022eb517593e86b90364e..c61a53a18ffc2aced7f5ec21f31e2fe4f4916522 100644 --- a/internal/ui/list/highlight.go +++ b/internal/ui/list/highlight.go @@ -31,6 +31,14 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin styled := uv.NewStyledString(content) styled.Draw(&buf, area) + // Treat -1 as "end of content" + if endLine < 0 { + endLine = height - 1 + } + if endCol < 0 { + endCol = width + } + for y := startLine; y <= endLine && y < height; y++ { if y >= buf.Height() { break diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index b540b5b6e62d452d4b966b6dc251a182cd543d90..66ae5bc51ad8d835350fc5edb510048d699d3977 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -1,6 +1,7 @@ package model import ( + "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" uv "github.com/charmbracelet/ultraviolet" @@ -63,7 +64,7 @@ func (m *Chat) PrependItems(items ...list.Item) { } // SetMessages sets the chat messages to the provided list of message items. -func (m *Chat) SetMessages(msgs ...MessageItem) { +func (m *Chat) SetMessages(msgs ...chat.MessageItem) { items := make([]list.Item, len(msgs)) for i, msg := range msgs { items[i] = msg @@ -73,7 +74,7 @@ func (m *Chat) SetMessages(msgs ...MessageItem) { } // AppendMessages appends a new message item to the chat list. -func (m *Chat) AppendMessages(msgs ...MessageItem) { +func (m *Chat) AppendMessages(msgs ...chat.MessageItem) { items := make([]list.Item, len(msgs)) for i, msg := range msgs { items[i] = msg diff --git a/internal/ui/model/items.go b/internal/ui/model/items.go deleted file mode 100644 index 789208df297fa4ab5ddeaa25651a8ac66ef5812a..0000000000000000000000000000000000000000 --- a/internal/ui/model/items.go +++ /dev/null @@ -1,548 +0,0 @@ -package model - -import ( - "fmt" - "path/filepath" - "strings" - "time" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/ui/common" - "github.com/charmbracelet/crush/internal/ui/list" - "github.com/charmbracelet/crush/internal/ui/styles" - "github.com/charmbracelet/crush/internal/ui/toolrender" -) - -// Identifiable is an interface for items that can provide a unique identifier. -type Identifiable interface { - ID() string -} - -// MessageItem represents a [message.Message] item that can be displayed in the -// UI and be part of a [list.List] identifiable by a unique ID. -type MessageItem interface { - list.Item - list.Item - Identifiable -} - -// MessageContentItem represents rendered message content (text, markdown, errors, etc). -type MessageContentItem struct { - id string - content string - role message.MessageRole - isMarkdown bool - maxWidth int - sty *styles.Styles -} - -// NewMessageContentItem creates a new message content item. -func NewMessageContentItem(id, content string, role message.MessageRole, isMarkdown bool, sty *styles.Styles) *MessageContentItem { - m := &MessageContentItem{ - id: id, - content: content, - isMarkdown: isMarkdown, - role: role, - maxWidth: 120, - sty: sty, - } - return m -} - -// ID implements Identifiable. -func (m *MessageContentItem) ID() string { - return m.id -} - -// FocusStyle returns the focus style. -func (m *MessageContentItem) FocusStyle() lipgloss.Style { - if m.role == message.User { - return m.sty.Chat.Message.UserFocused - } - return m.sty.Chat.Message.AssistantFocused -} - -// BlurStyle returns the blur style. -func (m *MessageContentItem) BlurStyle() lipgloss.Style { - if m.role == message.User { - return m.sty.Chat.Message.UserBlurred - } - return m.sty.Chat.Message.AssistantBlurred -} - -// HighlightStyle returns the highlight style. -func (m *MessageContentItem) HighlightStyle() lipgloss.Style { - return m.sty.TextSelection -} - -// Render renders the content at the given width, using cache if available. -// -// It implements [list.Item]. -func (m *MessageContentItem) Render(width int) string { - contentWidth := width - // Cap width to maxWidth for markdown - cappedWidth := contentWidth - if m.isMarkdown { - cappedWidth = min(contentWidth, m.maxWidth) - } - - var rendered string - if m.isMarkdown { - renderer := common.MarkdownRenderer(m.sty, cappedWidth) - result, err := renderer.Render(m.content) - if err != nil { - rendered = m.content - } else { - rendered = strings.TrimSuffix(result, "\n") - } - } else { - rendered = m.content - } - - return rendered -} - -// ToolCallItem represents a rendered tool call with its header and content. -type ToolCallItem struct { - id string - toolCall message.ToolCall - toolResult message.ToolResult - cancelled bool - isNested bool - maxWidth int - sty *styles.Styles -} - -// cachedToolRender stores both the rendered string and its height. -type cachedToolRender struct { - content string - height int -} - -// NewToolCallItem creates a new tool call item. -func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool, isNested bool, sty *styles.Styles) *ToolCallItem { - t := &ToolCallItem{ - id: id, - toolCall: toolCall, - toolResult: toolResult, - cancelled: cancelled, - isNested: isNested, - maxWidth: 120, - sty: sty, - } - return t -} - -// generateCacheKey creates a key that changes when tool call content changes. -func generateCacheKey(toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool) string { - // Simple key based on result state - when result arrives or changes, key changes - return fmt.Sprintf("%s:%s:%v", toolCall.ID, toolResult.ToolCallID, cancelled) -} - -// ID implements Identifiable. -func (t *ToolCallItem) ID() string { - return t.id -} - -// FocusStyle returns the focus style. -func (t *ToolCallItem) FocusStyle() lipgloss.Style { - return t.sty.Chat.Message.ToolCallFocused -} - -// BlurStyle returns the blur style. -func (t *ToolCallItem) BlurStyle() lipgloss.Style { - return t.sty.Chat.Message.ToolCallBlurred -} - -// HighlightStyle returns the highlight style. -func (t *ToolCallItem) HighlightStyle() lipgloss.Style { - return t.sty.TextSelection -} - -// Render implements list.Item. -func (t *ToolCallItem) Render(width int) string { - // Render the tool call - ctx := &toolrender.RenderContext{ - Call: t.toolCall, - Result: t.toolResult, - Cancelled: t.cancelled, - IsNested: t.isNested, - Width: width, - Styles: t.sty, - } - - rendered := toolrender.Render(ctx) - return rendered -} - -// AttachmentItem represents a file attachment in a user message. -type AttachmentItem struct { - id string - filename string - path string - sty *styles.Styles -} - -// NewAttachmentItem creates a new attachment item. -func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *AttachmentItem { - a := &AttachmentItem{ - id: id, - filename: filename, - path: path, - sty: sty, - } - return a -} - -// ID implements Identifiable. -func (a *AttachmentItem) ID() string { - return a.id -} - -// FocusStyle returns the focus style. -func (a *AttachmentItem) FocusStyle() lipgloss.Style { - return a.sty.Chat.Message.AssistantFocused -} - -// BlurStyle returns the blur style. -func (a *AttachmentItem) BlurStyle() lipgloss.Style { - return a.sty.Chat.Message.AssistantBlurred -} - -// HighlightStyle returns the highlight style. -func (a *AttachmentItem) HighlightStyle() lipgloss.Style { - return a.sty.TextSelection -} - -// Render implements list.Item. -func (a *AttachmentItem) Render(width int) string { - const maxFilenameWidth = 10 - content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf( - " %s %s ", - styles.DocumentIcon, - ansi.Truncate(a.filename, maxFilenameWidth, "..."), - )) - - return content - - // return a.RenderWithHighlight(content, width, a.CurrentStyle()) -} - -// ThinkingItem represents thinking/reasoning content in assistant messages. -type ThinkingItem struct { - id string - thinking string - duration time.Duration - finished bool - maxWidth int - sty *styles.Styles -} - -// NewThinkingItem creates a new thinking item. -func NewThinkingItem(id, thinking string, duration time.Duration, finished bool, sty *styles.Styles) *ThinkingItem { - t := &ThinkingItem{ - id: id, - thinking: thinking, - duration: duration, - finished: finished, - maxWidth: 120, - sty: sty, - } - return t -} - -// ID implements Identifiable. -func (t *ThinkingItem) ID() string { - return t.id -} - -// FocusStyle returns the focus style. -func (t *ThinkingItem) FocusStyle() lipgloss.Style { - return t.sty.Chat.Message.AssistantFocused -} - -// BlurStyle returns the blur style. -func (t *ThinkingItem) BlurStyle() lipgloss.Style { - return t.sty.Chat.Message.AssistantBlurred -} - -// HighlightStyle returns the highlight style. -func (t *ThinkingItem) HighlightStyle() lipgloss.Style { - return t.sty.TextSelection -} - -// Render implements list.Item. -func (t *ThinkingItem) Render(width int) string { - cappedWidth := min(width, t.maxWidth) - - renderer := common.PlainMarkdownRenderer(cappedWidth - 1) - rendered, err := renderer.Render(t.thinking) - if err != nil { - // Fallback to line-by-line rendering - lines := strings.Split(t.thinking, "\n") - var content strings.Builder - lineStyle := t.sty.PanelMuted - for i, line := range lines { - if line == "" { - continue - } - content.WriteString(lineStyle.Width(cappedWidth).Render(line)) - if i < len(lines)-1 { - content.WriteString("\n") - } - } - rendered = content.String() - } - - fullContent := strings.TrimSpace(rendered) - - // Add footer if finished - if t.finished && t.duration > 0 { - footer := t.sty.Chat.Message.ThinkingFooter.Render(fmt.Sprintf("Thought for %s", t.duration.String())) - fullContent = lipgloss.JoinVertical(lipgloss.Left, fullContent, "", footer) - } - - result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent) - - return result -} - -// SectionHeaderItem represents a section header (e.g., assistant info). -type SectionHeaderItem struct { - id string - modelName string - duration time.Duration - isSectionHeader bool - sty *styles.Styles - content string -} - -// NewSectionHeaderItem creates a new section header item. -func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *styles.Styles) *SectionHeaderItem { - s := &SectionHeaderItem{ - id: id, - modelName: modelName, - duration: duration, - isSectionHeader: true, - sty: sty, - } - return s -} - -// ID implements Identifiable. -func (s *SectionHeaderItem) ID() string { - return s.id -} - -// IsSectionHeader returns true if this is a section header. -func (s *SectionHeaderItem) IsSectionHeader() bool { - return s.isSectionHeader -} - -// FocusStyle returns the focus style. -func (s *SectionHeaderItem) FocusStyle() lipgloss.Style { - return s.sty.Chat.Message.AssistantFocused -} - -// BlurStyle returns the blur style. -func (s *SectionHeaderItem) BlurStyle() lipgloss.Style { - return s.sty.Chat.Message.AssistantBlurred -} - -// Render implements list.Item. -func (s *SectionHeaderItem) Render(width int) string { - content := fmt.Sprintf("%s %s %s", - s.sty.Subtle.Render(styles.ModelIcon), - s.sty.Muted.Render(s.modelName), - s.sty.Subtle.Render(s.duration.String()), - ) - - return s.sty.Chat.Message.SectionHeader.Render(content) -} - -// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns -// all parts of the message as [MessageItem]s. -// -// For assistant messages with tool calls, pass a toolResults map to link results. -// Use BuildToolResultMap to create this map from all messages in a session. -func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { - var items []MessageItem - - // Skip tool result messages - they're displayed inline with tool calls - if msg.Role == message.Tool { - return items - } - - // Process user messages - if msg.Role == message.User { - // Add main text content - content := msg.Content().String() - if content != "" { - item := NewMessageContentItem( - fmt.Sprintf("%s-content", msg.ID), - content, - msg.Role, - true, // User messages are markdown - sty, - ) - items = append(items, item) - } - - // Add attachments - for i, attachment := range msg.BinaryContent() { - filename := filepath.Base(attachment.Path) - item := NewAttachmentItem( - fmt.Sprintf("%s-attachment-%d", msg.ID, i), - filename, - attachment.Path, - sty, - ) - items = append(items, item) - } - - return items - } - - // Process assistant messages - if msg.Role == message.Assistant { - // Check if we need to add a section header - finishData := msg.FinishPart() - if finishData != nil && msg.Model != "" { - model := config.Get().GetModel(msg.Provider, msg.Model) - modelName := "Unknown Model" - if model != nil { - modelName = model.Name - } - - // Calculate duration (this would need the last user message time) - duration := time.Duration(0) - if finishData.Time > 0 { - duration = time.Duration(finishData.Time-msg.CreatedAt) * time.Second - } - - header := NewSectionHeaderItem( - fmt.Sprintf("%s-header", msg.ID), - modelName, - duration, - sty, - ) - items = append(items, header) - } - - // Add thinking content if present - reasoning := msg.ReasoningContent() - if strings.TrimSpace(reasoning.Thinking) != "" { - duration := time.Duration(0) - if reasoning.StartedAt > 0 && reasoning.FinishedAt > 0 { - duration = time.Duration(reasoning.FinishedAt-reasoning.StartedAt) * time.Second - } - - item := NewThinkingItem( - fmt.Sprintf("%s-thinking", msg.ID), - reasoning.Thinking, - duration, - reasoning.FinishedAt > 0, - sty, - ) - items = append(items, item) - } - - // Add main text content - content := msg.Content().String() - finished := msg.IsFinished() - - // Handle special finish states - if finished && content == "" && finishData != nil { - switch finishData.Reason { - case message.FinishReasonEndTurn: - // No content to show - case message.FinishReasonCanceled: - item := NewMessageContentItem( - fmt.Sprintf("%s-content", msg.ID), - "*Canceled*", - msg.Role, - true, - sty, - ) - items = append(items, item) - case message.FinishReasonError: - // Render error - errTag := sty.Chat.Message.ErrorTag.Render("ERROR") - truncated := ansi.Truncate(finishData.Message, 100, "...") - title := fmt.Sprintf("%s %s", errTag, sty.Chat.Message.ErrorTitle.Render(truncated)) - details := sty.Chat.Message.ErrorDetails.Render(finishData.Details) - errorContent := fmt.Sprintf("%s\n\n%s", title, details) - - item := NewMessageContentItem( - fmt.Sprintf("%s-error", msg.ID), - errorContent, - msg.Role, - false, - sty, - ) - items = append(items, item) - } - } else if content != "" { - item := NewMessageContentItem( - fmt.Sprintf("%s-content", msg.ID), - content, - msg.Role, - true, // Assistant messages are markdown - sty, - ) - items = append(items, item) - } - - // Add tool calls - toolCalls := msg.ToolCalls() - - // Use passed-in tool results map (if nil, use empty map) - resultMap := toolResults - if resultMap == nil { - resultMap = make(map[string]message.ToolResult) - } - - for _, tc := range toolCalls { - result, hasResult := resultMap[tc.ID] - if !hasResult { - result = message.ToolResult{} - } - - item := NewToolCallItem( - fmt.Sprintf("%s-tool-%s", msg.ID, tc.ID), - tc, - result, - false, // cancelled state would need to be tracked separately - false, // nested state would be detected from tool results - sty, - ) - - items = append(items, item) - } - - return items - } - - return items -} - -// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages. -// Tool result messages (role == message.Tool) contain the results that should be linked -// to tool calls in assistant messages. -func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult { - resultMap := make(map[string]message.ToolResult) - for _, msg := range messages { - if msg.Role == message.Tool { - for _, result := range msg.ToolResults() { - if result.ToolCallID != "" { - resultMap[result.ToolCallID] = result - } - } - } - } - return resultMap -} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 2c0ab9e85c1b57803c8424bb29eb5a5c0774357e..93367cdd05842279984027f228e053d1504f1c52 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -26,9 +26,9 @@ import ( "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" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" "github.com/charmbracelet/crush/internal/ui/logo" @@ -203,13 +203,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, uiutil.ReportError(err)) break } + m.setSessionMessages(msgs) - if cmd := m.handleMessageEvents(msgs...); cmd != nil { - cmds = append(cmds, cmd) - } case pubsub.Event[message.Message]: // TODO: Finish implementing me - cmds = append(cmds, m.handleMessageEvents(msg.Payload)) + // cmds = append(cmds, m.setMessageEvents(msg.Payload)) case pubsub.Event[history.File]: cmds = append(cmds, m.handleFileEvent(msg.Payload)) case pubsub.Event[app.LSPEvent]: @@ -337,29 +335,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *UI) handleMessageEvents(msgs ...message.Message) tea.Cmd { +// setSessionMessages sets the messages for the current session in the chat +func (m *UI) setSessionMessages(msgs []message.Message) { // Build tool result map to link tool calls with their results msgPtrs := make([]*message.Message, len(msgs)) for i := range msgs { msgPtrs[i] = &msgs[i] } - toolResultMap := BuildToolResultMap(msgPtrs) + toolResultMap := chat.BuildToolResultMap(msgPtrs) // Add messages to chat with linked tool results - items := make([]MessageItem, 0, len(msgs)*2) + items := make([]chat.MessageItem, 0, len(msgs)*2) for _, msg := range msgPtrs { - items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...) + items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...) } - if m.session == nil || m.session.ID == "" { - m.chat.SetMessages(items...) - } else { - m.chat.AppendMessages(items...) - } + m.chat.SetMessages(items...) m.chat.ScrollToBottom() m.chat.SelectLast() - - return nil } func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { @@ -1179,7 +1172,7 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C return uiutil.ReportError(err) } session = newSession - cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session))) + cmds = append(cmds, m.loadSession(session.ID)) } if m.com.App.AgentCoordinator == nil { return util.ReportError(fmt.Errorf("coder agent is not initialized")) From 3d9d4e6a3d2c800d52269b0d234e10df56029a2f Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 16:35:09 +0100 Subject: [PATCH 065/335] refactor(chat): simple assistant message --- cspell.json | 2 +- internal/ui/anim/anim.go | 445 ++++++++++++++++ internal/ui/chat/assistant.go | 227 ++++++++ internal/ui/chat/messages.go | 45 +- internal/ui/chat/user.go | 10 +- internal/ui/common/markdown.go | 9 +- internal/ui/model/chat.go | 46 +- internal/ui/model/ui.go | 86 ++- internal/ui/styles/styles.go | 182 ++++++- internal/ui/toolrender/render.go | 889 ------------------------------- 10 files changed, 1006 insertions(+), 935 deletions(-) create mode 100644 internal/ui/anim/anim.go create mode 100644 internal/ui/chat/assistant.go delete mode 100644 internal/ui/toolrender/render.go diff --git a/cspell.json b/cspell.json index c0faed860420b452c9f22592a27a327b8895f2fc..368a9d5094dc6ebd1850f118533dc41050b0eb90 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"version":"0.2","language":"en","flagWords":[],"words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync","Highlightable","Highlightable","prerendered","prerender","kujtim"]} \ No newline at end of file +{"words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync","Highlightable","Highlightable","prerendered","prerender","kujtim","animatable"],"version":"0.2","flagWords":[],"language":"en"} \ No newline at end of file diff --git a/internal/ui/anim/anim.go b/internal/ui/anim/anim.go new file mode 100644 index 0000000000000000000000000000000000000000..3e159b102324a68bb93b8f9cbd3e128bf60dcf0f --- /dev/null +++ b/internal/ui/anim/anim.go @@ -0,0 +1,445 @@ +// 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" +) + +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 string } + +// Settings defines settings for the animation. +type Settings struct { + ID string + 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 string +} + +// 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 + } + + if opts.ID != "" { + a.id = opts.ID + } else { + a.id = fmt.Sprintf("%d", 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 +} + +// Start starts the animation. +func (a *Anim) Start() tea.Cmd { + return a.Step() +} + +// Animate advances the animation to the next step. +func (a *Anim) Animate(msg StepMsg) tea.Cmd { + if msg.ID != a.id { + return 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.Step() +} + +// Render renders the current state of the animation. +func (a *Anim) Render() 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/ui/chat/assistant.go b/internal/ui/chat/assistant.go new file mode 100644 index 0000000000000000000000000000000000000000..a6d643d1df37eaf1f98736d0296f8e22d770209f --- /dev/null +++ b/internal/ui/chat/assistant.go @@ -0,0 +1,227 @@ +package chat + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// maxCollapsedThinkingHeight defines the maximum height of the thinking +const maxCollapsedThinkingHeight = 10 + +// AssistantMessageItem represents an assistant message in the chat UI. +// +// This item includes thinking, and the content but does not include the tool calls. +type AssistantMessageItem struct { + *highlightableMessageItem + *cachedMessageItem + *focusableMessageItem + + message *message.Message + sty *styles.Styles + anim *anim.Anim + thinkingExpanded bool + thinkingBoxHeight int // Tracks the rendered thinking box height for click detection. +} + +// NewAssistantMessageItem creates a new AssistantMessageItem. +func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem { + a := &AssistantMessageItem{ + highlightableMessageItem: defaultHighlighter(sty), + cachedMessageItem: &cachedMessageItem{}, + focusableMessageItem: &focusableMessageItem{}, + message: message, + sty: sty, + } + + a.anim = anim.New(anim.Settings{ + ID: a.ID(), + Size: 15, + GradColorA: sty.Primary, + GradColorB: sty.Secondary, + LabelColor: sty.FgBase, + CycleColors: true, + }) + return a +} + +func (a *AssistantMessageItem) StartAnimation() tea.Cmd { + if !a.isSpinning() { + return nil + } + return a.anim.Start() +} + +func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd { + if !a.isSpinning() { + return nil + } + return a.anim.Animate(msg) +} + +// ID implements MessageItem. +func (a *AssistantMessageItem) ID() string { + return a.message.ID +} + +// Render implements MessageItem. +func (a *AssistantMessageItem) Render(width int) string { + cappedWidth := cappedMessageWidth(width) + style := a.sty.Chat.Message.AssistantBlurred + if a.focused { + style = a.sty.Chat.Message.AssistantFocused + } + + var spinner string + if a.isSpinning() { + spinner = a.renderSpinning() + } + + content, height, ok := a.getCachedRender(cappedWidth) + if !ok { + content = a.renderMessageContent(cappedWidth) + height = lipgloss.Height(content) + // cache the rendered content + a.setCachedRender(content, cappedWidth, height) + } + + highlightedContent := a.renderHighlighted(content, cappedWidth, height) + if spinner != "" { + if highlightedContent != "" { + highlightedContent += "\n\n" + } + return style.Render(highlightedContent + spinner) + } + + return style.Render(highlightedContent) +} + +func (a *AssistantMessageItem) renderMessageContent(width int) string { + var messageParts []string + thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking) + content := strings.TrimSpace(a.message.Content().Text) + // if the massage has reasoning content add that first + if thinking != "" { + messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width)) + } + + // then add the main content + if content != "" { + // add a spacer between thinking and content + if thinking != "" { + messageParts = append(messageParts, "") + } + messageParts = append(messageParts, a.renderMarkdown(content, width)) + } + + // finally add any finish reason info + if a.message.IsFinished() { + switch a.message.FinishReason() { + case message.FinishReasonCanceled: + messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled")) + case message.FinishReasonError: + messageParts = append(messageParts, a.renderError(width)) + } + } + + return strings.Join(messageParts, "\n") +} + +// renderThinking renders the thinking/reasoning content with footer. +func (a *AssistantMessageItem) renderThinking(thinking string, width int) string { + renderer := common.PlainMarkdownRenderer(a.sty, width) + rendered, err := renderer.Render(thinking) + if err != nil { + rendered = thinking + } + rendered = strings.TrimSpace(rendered) + + lines := strings.Split(rendered, "\n") + totalLines := len(lines) + + isTruncated := totalLines > maxCollapsedThinkingHeight + if !a.thinkingExpanded && isTruncated { + lines = lines[totalLines-maxCollapsedThinkingHeight:] + } + + if !a.thinkingExpanded && isTruncated { + hint := a.sty.Chat.Message.ThinkingTruncationHint.Render( + fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight), + ) + lines = append([]string{hint}, lines...) + } + + thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width) + result := thinkingStyle.Render(strings.Join(lines, "\n")) + a.thinkingBoxHeight = lipgloss.Height(result) + + var footer string + // if thinking is done add the thought for footer + if !a.message.IsThinking() { + duration := a.message.ThinkingDuration() + if duration.String() != "0s" { + footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") + + a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String()) + } + } + + if footer != "" { + result += "\n\n" + footer + } + + return result +} + +// renderMarkdown renders content as markdown. +func (a *AssistantMessageItem) renderMarkdown(content string, width int) string { + renderer := common.MarkdownRenderer(a.sty, width) + result, err := renderer.Render(content) + if err != nil { + return content + } + return strings.TrimSuffix(result, "\n") +} + +func (a *AssistantMessageItem) renderSpinning() string { + if a.message.IsThinking() { + a.anim.SetLabel("Thinking") + } else if a.message.IsSummaryMessage { + a.anim.SetLabel("Summarizing") + } + return a.anim.Render() +} + +// renderError renders an error message. +func (a *AssistantMessageItem) renderError(width int) string { + finishPart := a.message.FinishPart() + errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR") + truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...") + title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated)) + details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details) + return fmt.Sprintf("%s\n\n%s", title, details) +} + +// isSpinning returns true if the assistant message is still generating. +func (a *AssistantMessageItem) isSpinning() bool { + isThinking := a.message.IsThinking() + isFinished := a.message.IsFinished() + return isThinking || !isFinished +} + +// SetMessage is used to update the underlying message. +func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd { + wasSpinning := a.isSpinning() + a.message = message + a.clearCache() + if !wasSpinning && a.isSpinning() { + return a.StartAnimation() + } + return nil +} diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 5fd52ff854e15fc7170bb58175196ad3aeb47306..53c0e5f45bb169b6ddbc92bc92262b4e47e3a509 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -1,9 +1,15 @@ +// Package chat provides UI components for displaying and managing chat messages. +// It defines message item types that can be rendered in a list view, including +// support for highlighting, focusing, and caching rendered content. package chat import ( "image" + "strings" + tea "charm.land/bubbletea/v2" "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" ) @@ -20,6 +26,11 @@ type Identifiable interface { ID() string } +type Animatable interface { + StartAnimation() tea.Cmd + Animate(msg anim.StepMsg) tea.Cmd +} + // MessageItem represents a [message.Message] item that can be displayed in the // UI and be part of a [list.List] identifiable by a unique ID. type MessageItem interface { @@ -84,7 +95,7 @@ func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem { // cachedMessageItem caches rendered message content to avoid re-rendering. // -// This should be used by any message that can store a cahced version of its render. e.x user,assistant... and so on +// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on // // THOUGHT(kujtim): we should consider if its efficient to store the render for different widths // the issue with that could be memory usage @@ -111,6 +122,23 @@ func (c *cachedMessageItem) setCachedRender(rendered string, width, height int) c.height = height } +// clearCache clears the cached render. +func (c *cachedMessageItem) clearCache() { + c.rendered = "" + c.width = 0 + c.height = 0 +} + +// focusableMessageItem is a base struct for message items that can be focused. +type focusableMessageItem struct { + focused bool +} + +// SetFocused implements MessageItem. +func (f *focusableMessageItem) SetFocused(focused bool) { + f.focused = focused +} + // cappedMessageWidth returns the maximum width for message content for readability. func cappedMessageWidth(availableWidth int) int { return min(availableWidth-messageLeftPaddingTotal, maxTextWidth) @@ -125,10 +153,25 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s switch msg.Role { case message.User: return []MessageItem{NewUserMessageItem(sty, msg)} + case message.Assistant: + var items []MessageItem + if shouldRenderAssistantMessage(msg) { + items = append(items, NewAssistantMessageItem(sty, msg)) + } + return items } return []MessageItem{} } +func shouldRenderAssistantMessage(msg *message.Message) bool { + content := strings.TrimSpace(msg.Content().Text) + thinking := strings.TrimSpace(msg.ReasoningContent().Thinking) + isError := msg.FinishReason() == message.FinishReasonError + isCancelled := msg.FinishReason() == message.FinishReasonCanceled + hasToolCalls := len(msg.ToolCalls()) > 0 + return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled +} + // BuildToolResultMap creates a map of tool call IDs to their results from a list of messages. // Tool result messages (role == message.Tool) contain the results that should be linked // to tool calls in assistant messages. diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go index b3e1bebb16b1bfebe9036b189ca0cfb42c234805..17033db31b92a193573482d60256cdb6ed3efd4c 100644 --- a/internal/ui/chat/user.go +++ b/internal/ui/chat/user.go @@ -16,9 +16,10 @@ import ( type UserMessageItem struct { *highlightableMessageItem *cachedMessageItem + *focusableMessageItem + message *message.Message sty *styles.Styles - focused bool } // NewUserMessageItem creates a new UserMessageItem. @@ -26,9 +27,9 @@ func NewUserMessageItem(sty *styles.Styles, message *message.Message) MessageIte return &UserMessageItem{ highlightableMessageItem: defaultHighlighter(sty), cachedMessageItem: &cachedMessageItem{}, + focusableMessageItem: &focusableMessageItem{}, message: message, sty: sty, - focused: false, } } @@ -67,11 +68,6 @@ func (m *UserMessageItem) Render(width int) string { return style.Render(m.renderHighlighted(content, cappedWidth, height)) } -// SetFocused implements MessageItem. -func (m *UserMessageItem) SetFocused(focused bool) { - m.focused = focused -} - // ID implements MessageItem. func (m *UserMessageItem) ID() string { return m.message.ID diff --git a/internal/ui/common/markdown.go b/internal/ui/common/markdown.go index 361cbba2ff8bab34214f95980bd20b98a6ead62a..f5af8121d1667658725b4424a4ab303804c75b42 100644 --- a/internal/ui/common/markdown.go +++ b/internal/ui/common/markdown.go @@ -2,15 +2,14 @@ package common import ( "charm.land/glamour/v2" - gstyles "charm.land/glamour/v2/styles" "github.com/charmbracelet/crush/internal/ui/styles" ) // MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with // the given styles and width. -func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer { +func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer { r, _ := glamour.NewTermRenderer( - glamour.WithStyles(t.Markdown), + glamour.WithStyles(sty.Markdown), glamour.WithWordWrap(width), ) return r @@ -18,9 +17,9 @@ func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer { // PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors // (plain text with structure) and the given width. -func PlainMarkdownRenderer(width int) *glamour.TermRenderer { +func PlainMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer { r, _ := glamour.NewTermRenderer( - glamour.WithStyles(gstyles.ASCIIStyleConfig), + glamour.WithStyles(sty.PlainMarkdown), glamour.WithWordWrap(width), ) return r diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 66ae5bc51ad8d835350fc5edb510048d699d3977..5fa2dc8ed1d6ba4213daa6e9a7f198761e2d0568 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -1,6 +1,8 @@ package model import ( + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" @@ -11,8 +13,9 @@ import ( // Chat represents the chat UI model that handles chat interactions and // messages. type Chat struct { - com *common.Common - list *list.List + com *common.Common + list *list.List + idInxMap map[string]int // Map of message IDs to their indices in the list // Mouse state mouseDown bool @@ -27,7 +30,7 @@ type Chat struct { // NewChat creates a new instance of [Chat] that handles chat interactions and // messages. func NewChat(com *common.Common) *Chat { - c := &Chat{com: com} + c := &Chat{com: com, idInxMap: make(map[string]int)} l := list.NewList() l.SetGap(1) l.RegisterRenderCallback(c.applyHighlightRange) @@ -57,16 +60,11 @@ func (m *Chat) Len() int { return m.list.Len() } -// PrependItems prepends new items to the chat list. -func (m *Chat) PrependItems(items ...list.Item) { - m.list.PrependItems(items...) - m.list.ScrollToIndex(0) -} - // SetMessages sets the chat messages to the provided list of message items. func (m *Chat) SetMessages(msgs ...chat.MessageItem) { items := make([]list.Item, len(msgs)) for i, msg := range msgs { + m.idInxMap[msg.ID()] = i items[i] = msg } m.list.SetItems(items...) @@ -76,16 +74,25 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) { // AppendMessages appends a new message item to the chat list. func (m *Chat) AppendMessages(msgs ...chat.MessageItem) { items := make([]list.Item, len(msgs)) + indexOffset := len(m.idInxMap) for i, msg := range msgs { + m.idInxMap[msg.ID()] = indexOffset + i items[i] = msg } m.list.AppendItems(items...) } -// AppendItems appends new items to the chat list. -func (m *Chat) AppendItems(items ...list.Item) { - m.list.AppendItems(items...) - m.list.ScrollToIndex(m.list.Len() - 1) +// Animate animated items in the chat list. +func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd { + item, ok := m.idInxMap[msg.ID] + // Item with the given ID exists + if !ok { + return nil + } + if animatable, ok := m.list.ItemAt(item).(chat.Animatable); ok { + return animatable.Animate(msg) + } + return nil } // Focus sets the focus state of the chat component. @@ -158,6 +165,19 @@ func (m *Chat) SelectLastInView() { m.list.SelectLastInView() } +// GetMessageItem returns the message item at the given id. +func (m *Chat) GetMessageItem(id string) chat.MessageItem { + idx, ok := m.idInxMap[id] + if !ok { + return nil + } + item, ok := m.list.ItemAt(idx).(chat.MessageItem) + if !ok { + return nil + } + return item +} + // HandleMouseDown handles mouse down events for the chat component. func (m *Chat) HandleMouseDown(x, y int) bool { if m.list.Len() == 0 { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 93367cdd05842279984027f228e053d1504f1c52..5c738f6835a4666bd87315fb9ce43e498ba29d05 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -27,7 +27,7 @@ import ( "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" - "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" @@ -203,9 +203,20 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, uiutil.ReportError(err)) break } - m.setSessionMessages(msgs) + if cmd := m.setSessionMessages(msgs); cmd != nil { + cmds = append(cmds, cmd) + } case pubsub.Event[message.Message]: + if m.session == nil || msg.Payload.SessionID != m.session.ID { + break + } + switch msg.Type { + case pubsub.CreatedEvent: + cmds = append(cmds, m.appendSessionMessage(msg.Payload)) + case pubsub.UpdatedEvent: + cmds = append(cmds, m.updateSessionMessage(msg.Payload)) + } // TODO: Finish implementing me // cmds = append(cmds, m.setMessageEvents(msg.Payload)) case pubsub.Event[history.File]: @@ -304,6 +315,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } + case anim.StepMsg: + if m.state == uiChat { + if cmd := m.chat.Animate(msg); cmd != nil { + cmds = append(cmds, cmd) + } + } case tea.KeyPressMsg: if cmd := m.handleKeyPressMsg(msg); cmd != nil { cmds = append(cmds, cmd) @@ -336,7 +353,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // setSessionMessages sets the messages for the current session in the chat -func (m *UI) setSessionMessages(msgs []message.Message) { +func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { + var cmds []tea.Cmd // Build tool result map to link tool calls with their results msgPtrs := make([]*message.Message, len(msgs)) for i := range msgs { @@ -350,9 +368,47 @@ func (m *UI) setSessionMessages(msgs []message.Message) { items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...) } + // If the user switches between sessions while the agent is working we want + // to make sure the animations are shown. + for _, item := range items { + if animatable, ok := item.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + m.chat.SetMessages(items...) m.chat.ScrollToBottom() m.chat.SelectLast() + return tea.Batch(cmds...) +} + +func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { + items := chat.GetMessageItems(m.com.Styles, &msg, nil) + var cmds []tea.Cmd + for _, item := range items { + if animatable, ok := item.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + m.chat.AppendMessages(items...) + m.chat.ScrollToBottom() + return tea.Batch(cmds...) +} + +func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { + existingItem := m.chat.GetMessageItem(msg.ID) + switch msg.Role { + case message.Assistant: + if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok { + assistantItem.SetMessage(&msg) + } + } + + return nil } func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { @@ -1161,33 +1217,31 @@ func (m *UI) renderSidebarLogo(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.session == nil { - return uiutil.ReportError(fmt.Errorf("no session selected")) + if m.com.App.AgentCoordinator == nil { + return uiutil.ReportError(fmt.Errorf("coder agent is not initialized")) } - session := *m.session + var cmds []tea.Cmd - if m.session.ID == "" { + if m.session == nil || m.session.ID == "" { newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session") if err != nil { return uiutil.ReportError(err) } - session = newSession - cmds = append(cmds, m.loadSession(session.ID)) - } - if m.com.App.AgentCoordinator == nil { - return util.ReportError(fmt.Errorf("coder agent is not initialized")) + m.state = uiChat + m.session = &newSession + cmds = append(cmds, m.loadSession(newSession.ID)) } - m.chat.ScrollToBottom() + cmds = append(cmds, func() tea.Msg { - _, err := m.com.App.AgentCoordinator.Run(context.Background(), session.ID, content, attachments...) + _, err := m.com.App.AgentCoordinator.Run(context.Background(), m.session.ID, content, 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, + return uiutil.InfoMsg{ + Type: uiutil.InfoTypeError, Msg: err.Error(), } } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index c78cdcacd2b4a3636ae07fc2f75ccdf9fe984d9b..f16a080662c160c9e7c1ac9ca05dc6dae362f988 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -85,7 +85,8 @@ type Styles struct { ItemOnlineIcon lipgloss.Style // Markdown & Chroma - Markdown ansi.StyleConfig + Markdown ansi.StyleConfig + PlainMarkdown ansi.StyleConfig // Inputs TextInput textinput.Styles @@ -195,8 +196,14 @@ type Styles struct { Attachment lipgloss.Style ToolCallFocused lipgloss.Style ToolCallBlurred lipgloss.Style - ThinkingFooter lipgloss.Style SectionHeader lipgloss.Style + + // Thinking section styles + ThinkingBox lipgloss.Style // Background for thinking content + ThinkingTruncationHint lipgloss.Style // "… (N lines hidden)" hint + ThinkingFooterTitle lipgloss.Style // "Thought for" text + ThinkingFooterDuration lipgloss.Style // Duration value + ThinkingFooterCancelled lipgloss.Style // "*Canceled*" text } } @@ -667,6 +674,169 @@ func DefaultStyles() Styles { }, } + // PlainMarkdown style - muted colors on subtle background for thinking content. + plainBg := stringPtr(bgBaseLighter.Hex()) + plainFg := stringPtr(fgMuted.Hex()) + s.PlainMarkdown = ansi.StyleConfig{ + Document: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + BlockQuote: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + Indent: uintPtr(1), + IndentToken: stringPtr("│ "), + }, + List: ansi.StyleList{ + LevelIndent: defaultListIndent, + }, + Heading: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + BlockSuffix: "\n", + Bold: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H1: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Bold: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H2: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "## ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H3: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "### ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H4: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "#### ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H5: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "##### ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H6: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "###### ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + Strikethrough: ansi.StylePrimitive{ + CrossedOut: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + Emph: ansi.StylePrimitive{ + Italic: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + Strong: ansi.StylePrimitive{ + Bold: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + HorizontalRule: ansi.StylePrimitive{ + Format: "\n--------\n", + Color: plainFg, + BackgroundColor: plainBg, + }, + Item: ansi.StylePrimitive{ + BlockPrefix: "• ", + Color: plainFg, + BackgroundColor: plainBg, + }, + Enumeration: ansi.StylePrimitive{ + BlockPrefix: ". ", + Color: plainFg, + BackgroundColor: plainBg, + }, + Task: ansi.StyleTask{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + Ticked: "[✓] ", + Unticked: "[ ] ", + }, + Link: ansi.StylePrimitive{ + Underline: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + LinkText: ansi.StylePrimitive{ + Bold: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + Image: ansi.StylePrimitive{ + Underline: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + ImageText: ansi.StylePrimitive{ + Format: "Image: {{.text}} →", + Color: plainFg, + BackgroundColor: plainBg, + }, + Code: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + CodeBlock: ansi.StyleCodeBlock{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + Margin: uintPtr(defaultMargin), + }, + }, + Table: ansi.StyleTable{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + }, + DefinitionDescription: ansi.StylePrimitive{ + BlockPrefix: "\n ", + Color: plainFg, + BackgroundColor: plainBg, + }, + } + s.Help = help.Styles{ ShortKey: base.Foreground(fgMuted), ShortDesc: base.Foreground(fgSubtle), @@ -892,9 +1062,15 @@ func DefaultStyles() Styles { BorderLeft(true). BorderForeground(greenDark) s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2) - s.Chat.Message.ThinkingFooter = s.Base s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2) + // Thinking section styles + s.Chat.Message.ThinkingBox = s.Subtle.Background(bgBaseLighter) + s.Chat.Message.ThinkingTruncationHint = s.Muted + s.Chat.Message.ThinkingFooterTitle = s.Muted + s.Chat.Message.ThinkingFooterDuration = s.Subtle + s.Chat.Message.ThinkingFooterCancelled = s.Subtle + // Text selection. s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple) diff --git a/internal/ui/toolrender/render.go b/internal/ui/toolrender/render.go deleted file mode 100644 index 18895908583322a06e58de553a6291ba9f7b3448..0000000000000000000000000000000000000000 --- a/internal/ui/toolrender/render.go +++ /dev/null @@ -1,889 +0,0 @@ -package toolrender - -import ( - "cmp" - "encoding/json" - "fmt" - "strings" - - "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/message" - "github.com/charmbracelet/crush/internal/ui/common" - "github.com/charmbracelet/crush/internal/ui/styles" - "github.com/charmbracelet/x/ansi" -) - -// responseContextHeight limits the number of lines displayed in tool output. -const responseContextHeight = 10 - -// RenderContext provides the context needed for rendering a tool call. -type RenderContext struct { - Call message.ToolCall - Result message.ToolResult - Cancelled bool - IsNested bool - Width int - Styles *styles.Styles -} - -// TextWidth returns the available width for content accounting for borders. -func (rc *RenderContext) TextWidth() int { - if rc.IsNested { - return rc.Width - 6 - } - return rc.Width - 5 -} - -// Fit truncates content to fit within the specified width with ellipsis. -func (rc *RenderContext) Fit(content string, width int) string { - lineStyle := rc.Styles.Muted - dots := lineStyle.Render("…") - return ansi.Truncate(content, width, dots) -} - -// Render renders a tool call using the appropriate renderer based on tool name. -func Render(ctx *RenderContext) string { - switch ctx.Call.Name { - case tools.ViewToolName: - return renderView(ctx) - case tools.EditToolName: - return renderEdit(ctx) - case tools.MultiEditToolName: - return renderMultiEdit(ctx) - case tools.WriteToolName: - return renderWrite(ctx) - case tools.BashToolName: - return renderBash(ctx) - case tools.JobOutputToolName: - return renderJobOutput(ctx) - case tools.JobKillToolName: - return renderJobKill(ctx) - case tools.FetchToolName: - return renderSimpleFetch(ctx) - case tools.AgenticFetchToolName: - return renderAgenticFetch(ctx) - case tools.WebFetchToolName: - return renderWebFetch(ctx) - case tools.DownloadToolName: - return renderDownload(ctx) - case tools.GlobToolName: - return renderGlob(ctx) - case tools.GrepToolName: - return renderGrep(ctx) - case tools.LSToolName: - return renderLS(ctx) - case tools.SourcegraphToolName: - return renderSourcegraph(ctx) - case tools.DiagnosticsToolName: - return renderDiagnostics(ctx) - case agent.AgentToolName: - return renderAgent(ctx) - default: - return renderGeneric(ctx) - } -} - -// Helper functions - -func unmarshalParams(input string, target any) error { - return json.Unmarshal([]byte(input), target) -} - -type paramBuilder struct { - args []string -} - -func newParamBuilder() *paramBuilder { - return ¶mBuilder{args: make([]string, 0)} -} - -func (pb *paramBuilder) addMain(value string) *paramBuilder { - if value != "" { - pb.args = append(pb.args, value) - } - return pb -} - -func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder { - if value != "" { - pb.args = append(pb.args, key, value) - } - return pb -} - -func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder { - if value { - pb.args = append(pb.args, key, "true") - } - return pb -} - -func (pb *paramBuilder) build() []string { - return pb.args -} - -func formatNonZero[T comparable](value T) string { - var zero T - if value == zero { - return "" - } - return fmt.Sprintf("%v", value) -} - -func makeHeader(ctx *RenderContext, toolName string, args []string) string { - if ctx.IsNested { - return makeNestedHeader(ctx, toolName, args) - } - s := ctx.Styles - var icon string - if ctx.Result.ToolCallID != "" { - if ctx.Result.IsError { - icon = s.Tool.IconError.Render() - } else { - icon = s.Tool.IconSuccess.Render() - } - } else if ctx.Cancelled { - icon = s.Tool.IconCancelled.Render() - } else { - icon = s.Tool.IconPending.Render() - } - tool := s.Tool.NameNormal.Render(toolName) - prefix := fmt.Sprintf("%s %s ", icon, tool) - return prefix + renderParamList(ctx, false, ctx.TextWidth()-lipgloss.Width(prefix), args...) -} - -func makeNestedHeader(ctx *RenderContext, toolName string, args []string) string { - s := ctx.Styles - var icon string - if ctx.Result.ToolCallID != "" { - if ctx.Result.IsError { - icon = s.Tool.IconError.Render() - } else { - icon = s.Tool.IconSuccess.Render() - } - } else if ctx.Cancelled { - icon = s.Tool.IconCancelled.Render() - } else { - icon = s.Tool.IconPending.Render() - } - tool := s.Tool.NameNested.Render(toolName) - prefix := fmt.Sprintf("%s %s ", icon, tool) - return prefix + renderParamList(ctx, true, ctx.TextWidth()-lipgloss.Width(prefix), args...) -} - -func renderParamList(ctx *RenderContext, nested bool, paramsWidth int, params ...string) string { - s := ctx.Styles - 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 s.Tool.ParamMain.Render(mainParam) - } - otherParams := params[1:] - 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 - if remainingWidth < 30 { - return s.Tool.ParamMain.Render(mainParam) - } - - if len(parts) > 0 { - mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", ")) - } - - return s.Tool.ParamMain.Render(ansi.Truncate(mainParam, paramsWidth, "…")) -} - -func earlyState(ctx *RenderContext, header string) (string, bool) { - s := ctx.Styles - message := "" - switch { - case ctx.Result.IsError: - message = renderToolError(ctx) - case ctx.Cancelled: - message = s.Tool.StateCancelled.Render("Canceled.") - case ctx.Result.ToolCallID == "": - message = s.Tool.StateWaiting.Render("Waiting for tool response...") - default: - return "", false - } - - message = s.Tool.BodyPadding.Render(message) - return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true -} - -func renderToolError(ctx *RenderContext) string { - s := ctx.Styles - errTag := s.Tool.ErrorTag.Render("ERROR") - msg := ctx.Result.Content - if msg == "" { - msg = "An error occurred" - } - truncated := ansi.Truncate(msg, ctx.TextWidth()-3-lipgloss.Width(errTag), "…") - return errTag + " " + s.Tool.ErrorMessage.Render(truncated) -} - -func joinHeaderBody(ctx *RenderContext, header, body string) string { - s := ctx.Styles - if body == "" { - return header - } - body = s.Tool.BodyPadding.Render(body) - return lipgloss.JoinVertical(lipgloss.Left, header, "", body) -} - -func renderWithParams(ctx *RenderContext, toolName string, args []string, contentRenderer func() string) string { - header := makeHeader(ctx, toolName, args) - if ctx.IsNested { - return header - } - if res, done := earlyState(ctx, header); done { - return res - } - body := contentRenderer() - return joinHeaderBody(ctx, header, body) -} - -func renderError(ctx *RenderContext, message string) string { - s := ctx.Styles - header := makeHeader(ctx, prettifyToolName(ctx.Call.Name), []string{}) - errorTag := s.Tool.ErrorTag.Render("ERROR") - message = s.Tool.ErrorMessage.Render(ctx.Fit(message, ctx.TextWidth()-3-lipgloss.Width(errorTag))) - return joinHeaderBody(ctx, header, errorTag+" "+message) -} - -func renderPlainContent(ctx *RenderContext, content string) string { - s := ctx.Styles - content = strings.ReplaceAll(content, "\r\n", "\n") - content = strings.ReplaceAll(content, "\t", " ") - content = strings.TrimSpace(content) - lines := strings.Split(content, "\n") - - width := ctx.TextWidth() - 2 - var out []string - for i, ln := range lines { - if i >= responseContextHeight { - break - } - ln = ansiext.Escape(ln) - ln = " " + ln - if len(ln) > width { - ln = ctx.Fit(ln, width) - } - out = append(out, s.Tool.ContentLine.Width(width).Render(ln)) - } - - if len(lines) > responseContextHeight { - out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight))) - } - - return strings.Join(out, "\n") -} - -func renderMarkdownContent(ctx *RenderContext, content string) string { - s := ctx.Styles - content = strings.ReplaceAll(content, "\r\n", "\n") - content = strings.ReplaceAll(content, "\t", " ") - content = strings.TrimSpace(content) - - width := ctx.TextWidth() - 2 - width = min(width, 120) - - renderer := common.PlainMarkdownRenderer(width) - rendered, err := renderer.Render(content) - if err != nil { - return renderPlainContent(ctx, content) - } - - lines := strings.Split(rendered, "\n") - - var out []string - for i, ln := range lines { - if i >= responseContextHeight { - break - } - out = append(out, ln) - } - - style := s.Tool.ContentLine - if len(lines) > responseContextHeight { - out = append(out, s.Tool.ContentTruncation. - Width(width-2). - Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight))) - } - - return style.Render(strings.Join(out, "\n")) -} - -func renderCodeContent(ctx *RenderContext, path, content string, offset int) string { - s := ctx.Styles - content = strings.ReplaceAll(content, "\r\n", "\n") - content = strings.ReplaceAll(content, "\t", " ") - truncated := truncateHeight(content, responseContextHeight) - - lines := strings.Split(truncated, "\n") - for i, ln := range lines { - lines[i] = ansiext.Escape(ln) - } - - bg := s.Tool.ContentCodeBg - highlighted, _ := common.SyntaxHighlight(ctx.Styles, strings.Join(lines, "\n"), path, bg) - lines = strings.Split(highlighted, "\n") - - width := ctx.TextWidth() - 2 - gutterWidth := getDigits(offset+len(lines)) + 1 - - var out []string - for i, ln := range lines { - lineNum := fmt.Sprintf("%*d", gutterWidth, offset+i+1) - gutter := s.Subtle.Render(lineNum + " ") - ln = " " + ln - if lipgloss.Width(gutter+ln) > width { - ln = ctx.Fit(ln, width-lipgloss.Width(gutter)) - } - out = append(out, s.Tool.ContentCodeLine.Width(width).Render(gutter+ln)) - } - - contentLines := strings.Split(content, "\n") - if len(contentLines) > responseContextHeight { - out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))) - } - - return 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 truncateHeight(content string, maxLines int) string { - lines := strings.Split(content, "\n") - if len(lines) <= maxLines { - return content - } - return strings.Join(lines[:maxLines], "\n") -} - -func prettifyToolName(name string) string { - switch name { - case "agent": - return "Agent" - case "bash": - return "Bash" - case "job_output": - return "Job: Output" - case "job_kill": - return "Job: Kill" - case "download": - return "Download" - case "edit": - return "Edit" - case "multiedit": - return "Multi-Edit" - case "fetch": - return "Fetch" - case "agentic_fetch": - return "Agentic Fetch" - case "web_fetch": - return "Fetching" - case "glob": - return "Glob" - case "grep": - return "Grep" - case "ls": - return "List" - case "sourcegraph": - return "Sourcegraph" - case "view": - return "View" - case "write": - return "Write" - case "lsp_references": - return "Find References" - case "lsp_diagnostics": - return "Diagnostics" - default: - parts := strings.Split(name, "_") - for i := range parts { - if len(parts[i]) > 0 { - parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] - } - } - return strings.Join(parts, " ") - } -} - -// Tool-specific renderers - -func renderGeneric(ctx *RenderContext) string { - return renderWithParams(ctx, prettifyToolName(ctx.Call.Name), []string{ctx.Call.Input}, func() string { - return renderPlainContent(ctx, ctx.Result.Content) - }) -} - -func renderView(ctx *RenderContext) string { - var params tools.ViewParams - if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { - return renderError(ctx, "Invalid view parameters") - } - - file := fsext.PrettyPath(params.FilePath) - args := newParamBuilder(). - addMain(file). - addKeyValue("limit", formatNonZero(params.Limit)). - addKeyValue("offset", formatNonZero(params.Offset)). - build() - - return renderWithParams(ctx, "View", args, func() string { - var meta tools.ViewResponseMetadata - if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil { - return renderPlainContent(ctx, ctx.Result.Content) - } - return renderCodeContent(ctx, meta.FilePath, meta.Content, params.Offset) - }) -} - -func renderEdit(ctx *RenderContext) string { - s := ctx.Styles - var params tools.EditParams - var args []string - if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil { - file := fsext.PrettyPath(params.FilePath) - args = newParamBuilder().addMain(file).build() - } - - return renderWithParams(ctx, "Edit", args, func() string { - var meta tools.EditResponseMetadata - if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil { - return renderPlainContent(ctx, ctx.Result.Content) - } - - formatter := common.DiffFormatter(ctx.Styles). - Before(fsext.PrettyPath(params.FilePath), meta.OldContent). - After(fsext.PrettyPath(params.FilePath), meta.NewContent). - Width(ctx.TextWidth() - 2) - if ctx.TextWidth() > 120 { - formatter = formatter.Split() - } - formatted := formatter.String() - if lipgloss.Height(formatted) > responseContextHeight { - contentLines := strings.Split(formatted, "\n") - truncateMessage := s.Tool.DiffTruncation. - Width(ctx.TextWidth() - 2). - Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight)) - formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage - } - return formatted - }) -} - -func renderMultiEdit(ctx *RenderContext) string { - s := ctx.Styles - var params tools.MultiEditParams - var args []string - if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil { - file := fsext.PrettyPath(params.FilePath) - args = newParamBuilder(). - addMain(file). - addKeyValue("edits", fmt.Sprintf("%d", len(params.Edits))). - build() - } - - return renderWithParams(ctx, "Multi-Edit", args, func() string { - var meta tools.MultiEditResponseMetadata - if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil { - return renderPlainContent(ctx, ctx.Result.Content) - } - - formatter := common.DiffFormatter(ctx.Styles). - Before(fsext.PrettyPath(params.FilePath), meta.OldContent). - After(fsext.PrettyPath(params.FilePath), meta.NewContent). - Width(ctx.TextWidth() - 2) - if ctx.TextWidth() > 120 { - formatter = formatter.Split() - } - formatted := formatter.String() - if lipgloss.Height(formatted) > responseContextHeight { - contentLines := strings.Split(formatted, "\n") - truncateMessage := s.Tool.DiffTruncation. - Width(ctx.TextWidth() - 2). - Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight)) - formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage - } - - // Add note about failed edits if any. - if len(meta.EditsFailed) > 0 { - noteTag := s.Tool.NoteTag.Render("NOTE") - noteMsg := s.Tool.NoteMessage.Render( - fmt.Sprintf("%d of %d edits failed", len(meta.EditsFailed), len(params.Edits))) - formatted = formatted + "\n\n" + noteTag + " " + noteMsg - } - - return formatted - }) -} - -func renderWrite(ctx *RenderContext) string { - var params tools.WriteParams - if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { - return renderError(ctx, "Invalid write parameters") - } - - file := fsext.PrettyPath(params.FilePath) - args := newParamBuilder().addMain(file).build() - - return renderWithParams(ctx, "Write", args, func() string { - return renderCodeContent(ctx, params.FilePath, params.Content, 0) - }) -} - -func renderBash(ctx *RenderContext) string { - var params tools.BashParams - if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { - return renderError(ctx, "Invalid bash parameters") - } - - cmd := strings.ReplaceAll(params.Command, "\n", " ") - cmd = strings.ReplaceAll(cmd, "\t", " ") - args := newParamBuilder(). - addMain(cmd). - addFlag("background", params.RunInBackground). - build() - - if ctx.Call.Finished { - var meta tools.BashResponseMetadata - _ = unmarshalParams(ctx.Result.Metadata, &meta) - if meta.Background { - description := cmp.Or(meta.Description, params.Command) - width := ctx.TextWidth() - if ctx.IsNested { - width -= 4 - } - header := makeJobHeader(ctx, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width) - if ctx.IsNested { - return header - } - if res, done := earlyState(ctx, header); done { - return res - } - content := "Command: " + params.Command + "\n" + ctx.Result.Content - body := renderPlainContent(ctx, content) - return joinHeaderBody(ctx, header, body) - } - } - - return renderWithParams(ctx, "Bash", args, func() string { - var meta tools.BashResponseMetadata - if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil { - return renderPlainContent(ctx, ctx.Result.Content) - } - if meta.Output == "" && ctx.Result.Content != tools.BashNoOutput { - meta.Output = ctx.Result.Content - } - - if meta.Output == "" { - return "" - } - return renderPlainContent(ctx, meta.Output) - }) -} - -func makeJobHeader(ctx *RenderContext, action, pid, description string, width int) string { - s := ctx.Styles - icon := s.Tool.JobIconPending.Render(styles.ToolPending) - if ctx.Result.ToolCallID != "" { - if ctx.Result.IsError { - icon = s.Tool.JobIconError.Render(styles.ToolError) - } else { - icon = s.Tool.JobIconSuccess.Render(styles.ToolSuccess) - } - } else if ctx.Cancelled { - icon = s.Muted.Render(styles.ToolPending) - } - - toolName := s.Tool.JobToolName.Render("Bash") - actionPart := s.Tool.JobAction.Render(action) - pidPart := s.Tool.JobPID.Render(pid) - - prefix := fmt.Sprintf("%s %s %s %s ", icon, toolName, actionPart, pidPart) - remainingWidth := width - lipgloss.Width(prefix) - - descDisplay := ansi.Truncate(description, remainingWidth, "…") - descDisplay = s.Tool.JobDescription.Render(descDisplay) - - return prefix + descDisplay -} - -func renderJobOutput(ctx *RenderContext) string { - var params tools.JobOutputParams - if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { - return renderError(ctx, "Invalid job output parameters") - } - - width := ctx.TextWidth() - if ctx.IsNested { - width -= 4 - } - - var meta tools.JobOutputResponseMetadata - _ = unmarshalParams(ctx.Result.Metadata, &meta) - description := cmp.Or(meta.Description, meta.Command) - - header := makeJobHeader(ctx, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width) - if ctx.IsNested { - return header - } - if res, done := earlyState(ctx, header); done { - return res - } - body := renderPlainContent(ctx, ctx.Result.Content) - return joinHeaderBody(ctx, header, body) -} - -func renderJobKill(ctx *RenderContext) string { - var params tools.JobKillParams - if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { - return renderError(ctx, "Invalid job kill parameters") - } - - width := ctx.TextWidth() - if ctx.IsNested { - width -= 4 - } - - var meta tools.JobKillResponseMetadata - _ = unmarshalParams(ctx.Result.Metadata, &meta) - description := cmp.Or(meta.Description, meta.Command) - - header := makeJobHeader(ctx, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width) - if ctx.IsNested { - return header - } - if res, done := earlyState(ctx, header); done { - return res - } - body := renderPlainContent(ctx, ctx.Result.Content) - return joinHeaderBody(ctx, header, body) -} - -func renderSimpleFetch(ctx *RenderContext) string { - var params tools.FetchParams - if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { - return renderError(ctx, "Invalid fetch parameters") - } - - args := newParamBuilder(). - addMain(params.URL). - addKeyValue("format", params.Format). - addKeyValue("timeout", formatNonZero(params.Timeout)). - build() - - return renderWithParams(ctx, "Fetch", args, func() string { - path := "file." + params.Format - return renderCodeContent(ctx, path, ctx.Result.Content, 0) - }) -} - -func renderAgenticFetch(ctx *RenderContext) string { - // TODO: Implement nested tool call rendering with tree. - return renderGeneric(ctx) -} - -func renderWebFetch(ctx *RenderContext) string { - var params tools.WebFetchParams - if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { - return renderError(ctx, "Invalid web fetch parameters") - } - - args := newParamBuilder().addMain(params.URL).build() - - return renderWithParams(ctx, "Fetching", args, func() string { - return renderMarkdownContent(ctx, ctx.Result.Content) - }) -} - -func renderDownload(ctx *RenderContext) string { - var params tools.DownloadParams - if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { - return renderError(ctx, "Invalid download parameters") - } - - args := newParamBuilder(). - addMain(params.URL). - addKeyValue("file", fsext.PrettyPath(params.FilePath)). - addKeyValue("timeout", formatNonZero(params.Timeout)). - build() - - return renderWithParams(ctx, "Download", args, func() string { - return renderPlainContent(ctx, ctx.Result.Content) - }) -} - -func renderGlob(ctx *RenderContext) string { - var params tools.GlobParams - if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { - return renderError(ctx, "Invalid glob parameters") - } - - args := newParamBuilder(). - addMain(params.Pattern). - addKeyValue("path", params.Path). - build() - - return renderWithParams(ctx, "Glob", args, func() string { - return renderPlainContent(ctx, ctx.Result.Content) - }) -} - -func renderGrep(ctx *RenderContext) string { - var params tools.GrepParams - var args []string - if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil { - args = newParamBuilder(). - addMain(params.Pattern). - addKeyValue("path", params.Path). - addKeyValue("include", params.Include). - addFlag("literal", params.LiteralText). - build() - } - - return renderWithParams(ctx, "Grep", args, func() string { - return renderPlainContent(ctx, ctx.Result.Content) - }) -} - -func renderLS(ctx *RenderContext) string { - var params tools.LSParams - path := cmp.Or(params.Path, ".") - args := newParamBuilder().addMain(path).build() - - if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil && params.Path != "" { - args = newParamBuilder().addMain(params.Path).build() - } - - return renderWithParams(ctx, "List", args, func() string { - return renderPlainContent(ctx, ctx.Result.Content) - }) -} - -func renderSourcegraph(ctx *RenderContext) string { - var params tools.SourcegraphParams - if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil { - return renderError(ctx, "Invalid sourcegraph parameters") - } - - args := newParamBuilder(). - addMain(params.Query). - addKeyValue("count", formatNonZero(params.Count)). - addKeyValue("context", formatNonZero(params.ContextWindow)). - build() - - return renderWithParams(ctx, "Sourcegraph", args, func() string { - return renderPlainContent(ctx, ctx.Result.Content) - }) -} - -func renderDiagnostics(ctx *RenderContext) string { - args := newParamBuilder().addMain("project").build() - - return renderWithParams(ctx, "Diagnostics", args, func() string { - return renderPlainContent(ctx, ctx.Result.Content) - }) -} - -func renderAgent(ctx *RenderContext) string { - s := ctx.Styles - var params agent.AgentParams - unmarshalParams(ctx.Call.Input, ¶ms) - - prompt := params.Prompt - prompt = strings.ReplaceAll(prompt, "\n", " ") - - header := makeHeader(ctx, "Agent", []string{}) - if res, done := earlyState(ctx, header); ctx.Cancelled && done { - return res - } - taskTag := s.Tool.AgentTaskTag.Render("Task") - remainingWidth := ctx.TextWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2 - remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2) - prompt = s.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) - header = lipgloss.JoinVertical( - lipgloss.Left, - header, - "", - lipgloss.JoinHorizontal( - lipgloss.Left, - taskTag, - " ", - prompt, - ), - ) - childTools := tree.Root(header) - - // TODO: Render nested tool calls when available. - - parts := []string{ - childTools.Enumerator(roundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(), - } - - if ctx.Result.ToolCallID == "" { - // Pending state - would show animation in TUI. - parts = append(parts, "", s.Subtle.Render("Working...")) - } - - header = lipgloss.JoinVertical( - lipgloss.Left, - parts..., - ) - - if ctx.Result.ToolCallID == "" { - return header - } - - body := renderMarkdownContent(ctx, ctx.Result.Content) - return joinHeaderBody(ctx, header, body) -} - -func roundedEnumeratorWithWidth(width int, offset int) func(tree.Children, int) string { - return func(children tree.Children, i int) string { - if children.Length()-1 == i { - return strings.Repeat(" ", offset) + "└" + strings.Repeat("─", width-1) + " " - } - return strings.Repeat(" ", offset) + "├" + strings.Repeat("─", width-1) + " " - } -} From 95bae1017e11e4312ef98d78eed21eceb2211087 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 16:41:52 +0100 Subject: [PATCH 066/335] chore(chat): add some missing docs --- internal/ui/chat/messages.go | 4 ++++ internal/ui/model/ui.go | 3 +++ 2 files changed, 7 insertions(+) diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 53c0e5f45bb169b6ddbc92bc92262b4e47e3a509..3e12efefa4db1c9d78cbfe5a44374f4a918290f4 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -163,6 +163,10 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s return []MessageItem{} } +// shouldRenderAssistantMessage determines if an assistant message should be rendered +// +// In some cases the assistant message only has tools so we do not want to render an +// empty message. func shouldRenderAssistantMessage(msg *message.Message) bool { content := strings.TrimSpace(msg.Content().Text) thinking := strings.TrimSpace(msg.ReasoningContent().Thinking) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 5c738f6835a4666bd87315fb9ce43e498ba29d05..2caa9ac9b910a4439d1d69a62c595dce94239695 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -384,6 +384,7 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { return tea.Batch(cmds...) } +// appendSessionMessage appends a new message to the current session in the chat func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { items := chat.GetMessageItems(m.com.Styles, &msg, nil) var cmds []tea.Cmd @@ -399,6 +400,8 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { return tea.Batch(cmds...) } +// updateSessionMessage updates an existing message in the current session in the chat +// INFO: currently only updates the assistant when I add tools this will get a bit more complex func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { existingItem := m.chat.GetMessageItem(msg.ID) switch msg.Role { From 8cea0883824de2cfb0627069333c579ea41648ee Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 16:45:23 +0100 Subject: [PATCH 067/335] chore(chat): remove unused style --- internal/ui/styles/styles.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index f16a080662c160c9e7c1ac9ca05dc6dae362f988..d6f96474c7daf3397ebebb9b819b2388790d7a95 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -199,11 +199,10 @@ type Styles struct { SectionHeader lipgloss.Style // Thinking section styles - ThinkingBox lipgloss.Style // Background for thinking content - ThinkingTruncationHint lipgloss.Style // "… (N lines hidden)" hint - ThinkingFooterTitle lipgloss.Style // "Thought for" text - ThinkingFooterDuration lipgloss.Style // Duration value - ThinkingFooterCancelled lipgloss.Style // "*Canceled*" text + ThinkingBox lipgloss.Style // Background for thinking content + ThinkingTruncationHint lipgloss.Style // "… (N lines hidden)" hint + ThinkingFooterTitle lipgloss.Style // "Thought for" text + ThinkingFooterDuration lipgloss.Style // Duration value } } @@ -1069,7 +1068,6 @@ func DefaultStyles() Styles { s.Chat.Message.ThinkingTruncationHint = s.Muted s.Chat.Message.ThinkingFooterTitle = s.Muted s.Chat.Message.ThinkingFooterDuration = s.Subtle - s.Chat.Message.ThinkingFooterCancelled = s.Subtle // Text selection. s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple) From 749a966f306f81cfa34ed7e56f6f0c4501674711 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 17 Dec 2025 10:50:33 -0500 Subject: [PATCH 068/335] fix(ui): ensure MCPs are displayed in configured order --- internal/ui/model/mcp.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go index 2a58e15ac10175f29d6180aa7e98d954a644b34b..4100907d2c58f4238eb080356a069cf9bd0a2da6 100644 --- a/internal/ui/model/mcp.go +++ b/internal/ui/model/mcp.go @@ -16,8 +16,10 @@ func (m *UI) mcpInfo(width, maxItems int, isSection bool) string { var mcps []mcp.ClientInfo t := m.com.Styles - for _, state := range m.mcpStates { - mcps = append(mcps, state) + for _, mcp := range m.com.Config().MCP.Sorted() { + if state, ok := m.mcpStates[mcp.Name]; ok { + mcps = append(mcps, state) + } } title := t.Subtle.Render("MCPs") From b8d39c6abdf3d8ff47b6c0cdb9b0d2fbb62ce1de Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 16:51:48 +0100 Subject: [PATCH 069/335] chore(chat): some more docs missing --- internal/ui/chat/assistant.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index a6d643d1df37eaf1f98736d0296f8e22d770209f..e4153075459473bca47c17f83ffbcfe81f151909 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -52,6 +52,7 @@ func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) Messa return a } +// StartAnimation starts the assistant message animation if it should be spinning. func (a *AssistantMessageItem) StartAnimation() tea.Cmd { if !a.isSpinning() { return nil @@ -59,6 +60,7 @@ func (a *AssistantMessageItem) StartAnimation() tea.Cmd { return a.anim.Start() } +// Animate progresses the assistant message animation if it should be spinning. func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd { if !a.isSpinning() { return nil @@ -103,6 +105,7 @@ func (a *AssistantMessageItem) Render(width int) string { return style.Render(highlightedContent) } +// renderMessageContent renders the message content including thinking, main content, and finish reason. func (a *AssistantMessageItem) renderMessageContent(width int) string { var messageParts []string thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking) From ed18aac98e9f06cc1ad6aa2bef0f779ff894dc43 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 18:38:34 +0100 Subject: [PATCH 070/335] refactor(chat): only show animations for items that are visible --- internal/ui/list/list.go | 12 ++--- internal/ui/model/chat.go | 99 +++++++++++++++++++++++++++++++++------ internal/ui/model/ui.go | 88 +++++++++++++++++++++++++--------- 3 files changed, 156 insertions(+), 43 deletions(-) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 17766cd52506132322c051fffaf33de613332315..3e9fe124b0ddf1e55b3e920bc2828d4efabbc996 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -189,9 +189,9 @@ func (l *List) ScrollBy(lines int) { } } -// findVisibleItems finds the range of items that are visible in the viewport. +// VisibleItemIndices finds the range of items that are visible in the viewport. // This is used for checking if selected item is in view. -func (l *List) findVisibleItems() (startIdx, endIdx int) { +func (l *List) VisibleItemIndices() (startIdx, endIdx int) { if len(l.items) == 0 { return 0, 0 } @@ -352,7 +352,7 @@ func (l *List) ScrollToSelected() { return } - startIdx, endIdx := l.findVisibleItems() + startIdx, endIdx := l.VisibleItemIndices() if l.selectedIdx < startIdx { // Selected item is above the visible range l.offsetIdx = l.selectedIdx @@ -385,7 +385,7 @@ func (l *List) SelectedItemInView() bool { if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { return false } - startIdx, endIdx := l.findVisibleItems() + startIdx, endIdx := l.VisibleItemIndices() return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx } @@ -453,13 +453,13 @@ func (l *List) SelectedItem() Item { // SelectFirstInView selects the first item currently in view. func (l *List) SelectFirstInView() { - startIdx, _ := l.findVisibleItems() + startIdx, _ := l.VisibleItemIndices() l.selectedIdx = startIdx } // SelectLastInView selects the last item currently in view. func (l *List) SelectLastInView() { - _, endIdx := l.findVisibleItems() + _, endIdx := l.VisibleItemIndices() l.selectedIdx = endIdx } diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 5fa2dc8ed1d6ba4213daa6e9a7f198761e2d0568..76b52ce7be311656a1e546bb6f5e261333c95e0a 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -17,6 +17,11 @@ type Chat struct { list *list.List idInxMap map[string]int // Map of message IDs to their indices in the list + // Animation visibility optimization: track animations paused due to items + // being scrolled out of view. When items become visible again, their + // animations are restarted. + pausedAnimations map[string]struct{} + // Mouse state mouseDown bool mouseDownItem int // Item index where mouse was pressed @@ -30,7 +35,11 @@ type Chat struct { // NewChat creates a new instance of [Chat] that handles chat interactions and // messages. func NewChat(com *common.Common) *Chat { - c := &Chat{com: com, idInxMap: make(map[string]int)} + c := &Chat{ + com: com, + idInxMap: make(map[string]int), + pausedAnimations: make(map[string]struct{}), + } l := list.NewList() l.SetGap(1) l.RegisterRenderCallback(c.applyHighlightRange) @@ -82,17 +91,69 @@ func (m *Chat) AppendMessages(msgs ...chat.MessageItem) { m.list.AppendItems(items...) } -// Animate animated items in the chat list. +// Animate animates items in the chat list. Only propagates animation messages +// to visible items to save CPU. When items are not visible, their animation ID +// is tracked so it can be restarted when they become visible again. func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd { - item, ok := m.idInxMap[msg.ID] - // Item with the given ID exists + idx, ok := m.idInxMap[msg.ID] + if !ok { + return nil + } + + animatable, ok := m.list.ItemAt(idx).(chat.Animatable) if !ok { return nil } - if animatable, ok := m.list.ItemAt(item).(chat.Animatable); ok { - return animatable.Animate(msg) + + // Check if item is currently visible. + startIdx, endIdx := m.list.VisibleItemIndices() + isVisible := idx >= startIdx && idx <= endIdx + + if !isVisible { + // Item not visible - pause animation by not propagating. + // Track it so we can restart when it becomes visible. + m.pausedAnimations[msg.ID] = struct{}{} + return nil + } + + // Item is visible - remove from paused set and animate. + delete(m.pausedAnimations, msg.ID) + return animatable.Animate(msg) +} + +// RestartPausedVisibleAnimations restarts animations for items that were paused +// due to being scrolled out of view but are now visible again. +func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd { + if len(m.pausedAnimations) == 0 { + return nil + } + + startIdx, endIdx := m.list.VisibleItemIndices() + var cmds []tea.Cmd + + for id := range m.pausedAnimations { + idx, ok := m.idInxMap[id] + if !ok { + // Item no longer exists. + delete(m.pausedAnimations, id) + continue + } + + if idx >= startIdx && idx <= endIdx { + // Item is now visible - restart its animation. + if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + delete(m.pausedAnimations, id) + } + } + + if len(cmds) == 0 { + return nil } - return nil + return tea.Batch(cmds...) } // Focus sets the focus state of the chat component. @@ -105,24 +166,32 @@ func (m *Chat) Blur() { m.list.Blur() } -// ScrollToTop scrolls the chat view to the top. -func (m *Chat) ScrollToTop() { +// ScrollToTop scrolls the chat view to the top and returns a command to restart +// any paused animations that are now visible. +func (m *Chat) ScrollToTop() tea.Cmd { m.list.ScrollToTop() + return m.RestartPausedVisibleAnimations() } -// ScrollToBottom scrolls the chat view to the bottom. -func (m *Chat) ScrollToBottom() { +// ScrollToBottom scrolls the chat view to the bottom and returns a command to +// restart any paused animations that are now visible. +func (m *Chat) ScrollToBottom() tea.Cmd { m.list.ScrollToBottom() + return m.RestartPausedVisibleAnimations() } -// ScrollBy scrolls the chat view by the given number of line deltas. -func (m *Chat) ScrollBy(lines int) { +// ScrollBy scrolls the chat view by the given number of line deltas and returns +// a command to restart any paused animations that are now visible. +func (m *Chat) ScrollBy(lines int) tea.Cmd { m.list.ScrollBy(lines) + return m.RestartPausedVisibleAnimations() } -// ScrollToSelected scrolls the chat view to the selected item. -func (m *Chat) ScrollToSelected() { +// ScrollToSelected scrolls the chat view to the selected item and returns a +// command to restart any paused animations that are now visible. +func (m *Chat) ScrollToSelected() tea.Cmd { m.list.ScrollToSelected() + return m.RestartPausedVisibleAnimations() } // SelectedItemInView returns whether the selected item is currently in view. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 2caa9ac9b910a4439d1d69a62c595dce94239695..dc581d4febe19a73ae12618a44f9ccd8bdf802b8 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -268,16 +268,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.state { case uiChat: if msg.Y <= 0 { - m.chat.ScrollBy(-1) + if cmd := m.chat.ScrollBy(-1); cmd != nil { + cmds = append(cmds, cmd) + } if !m.chat.SelectedItemInView() { m.chat.SelectPrev() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } } } else if msg.Y >= m.chat.Height()-1 { - m.chat.ScrollBy(1) + if cmd := m.chat.ScrollBy(1); cmd != nil { + cmds = append(cmds, cmd) + } if !m.chat.SelectedItemInView() { m.chat.SelectNext() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } } } @@ -302,16 +310,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case uiChat: switch msg.Button { case tea.MouseWheelUp: - m.chat.ScrollBy(-5) + if cmd := m.chat.ScrollBy(-5); cmd != nil { + cmds = append(cmds, cmd) + } if !m.chat.SelectedItemInView() { m.chat.SelectPrev() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } } case tea.MouseWheelDown: - m.chat.ScrollBy(5) + if cmd := m.chat.ScrollBy(5); cmd != nil { + cmds = append(cmds, cmd) + } if !m.chat.SelectedItemInView() { m.chat.SelectNext() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } } } } @@ -379,7 +395,9 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { } m.chat.SetMessages(items...) - m.chat.ScrollToBottom() + if cmd := m.chat.ScrollToBottom(); cmd != nil { + cmds = append(cmds, cmd) + } m.chat.SelectLast() return tea.Batch(cmds...) } @@ -396,7 +414,9 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { } } m.chat.AppendMessages(items...) - m.chat.ScrollToBottom() + if cmd := m.chat.ScrollToBottom(); cmd != nil { + cmds = append(cmds, cmd) + } return tea.Batch(cmds...) } @@ -558,40 +578,64 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { cmds = append(cmds, m.textarea.Focus()) m.chat.Blur() case key.Matches(msg, m.keyMap.Chat.Up): - m.chat.ScrollBy(-1) + if cmd := m.chat.ScrollBy(-1); cmd != nil { + cmds = append(cmds, cmd) + } if !m.chat.SelectedItemInView() { m.chat.SelectPrev() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } } case key.Matches(msg, m.keyMap.Chat.Down): - m.chat.ScrollBy(1) + if cmd := m.chat.ScrollBy(1); cmd != nil { + cmds = append(cmds, cmd) + } if !m.chat.SelectedItemInView() { m.chat.SelectNext() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } } case key.Matches(msg, m.keyMap.Chat.UpOneItem): m.chat.SelectPrev() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } case key.Matches(msg, m.keyMap.Chat.DownOneItem): m.chat.SelectNext() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } case key.Matches(msg, m.keyMap.Chat.HalfPageUp): - m.chat.ScrollBy(-m.chat.Height() / 2) + if cmd := m.chat.ScrollBy(-m.chat.Height() / 2); cmd != nil { + cmds = append(cmds, cmd) + } m.chat.SelectFirstInView() case key.Matches(msg, m.keyMap.Chat.HalfPageDown): - m.chat.ScrollBy(m.chat.Height() / 2) + if cmd := m.chat.ScrollBy(m.chat.Height() / 2); cmd != nil { + cmds = append(cmds, cmd) + } m.chat.SelectLastInView() case key.Matches(msg, m.keyMap.Chat.PageUp): - m.chat.ScrollBy(-m.chat.Height()) + if cmd := m.chat.ScrollBy(-m.chat.Height()); cmd != nil { + cmds = append(cmds, cmd) + } m.chat.SelectFirstInView() case key.Matches(msg, m.keyMap.Chat.PageDown): - m.chat.ScrollBy(m.chat.Height()) + if cmd := m.chat.ScrollBy(m.chat.Height()); cmd != nil { + cmds = append(cmds, cmd) + } m.chat.SelectLastInView() case key.Matches(msg, m.keyMap.Chat.Home): - m.chat.ScrollToTop() + if cmd := m.chat.ScrollToTop(); cmd != nil { + cmds = append(cmds, cmd) + } m.chat.SelectFirst() case key.Matches(msg, m.keyMap.Chat.End): - m.chat.ScrollToBottom() + if cmd := m.chat.ScrollToBottom(); cmd != nil { + cmds = append(cmds, cmd) + } m.chat.SelectLast() default: handleGlobalKeys(msg) From ff9cbf656f77745a147b285ea1a6311ca1533210 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 18:41:12 +0100 Subject: [PATCH 071/335] fix(chat): only spin when there is no and no tool calls --- internal/ui/chat/assistant.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index e4153075459473bca47c17f83ffbcfe81f151909..66fe97e1bc5a7d6242200835a72136c098935ab1 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -215,7 +215,9 @@ func (a *AssistantMessageItem) renderError(width int) string { func (a *AssistantMessageItem) isSpinning() bool { isThinking := a.message.IsThinking() isFinished := a.message.IsFinished() - return isThinking || !isFinished + hasContent := strings.TrimSpace(a.message.Content().Text) != "" + hasToolCalls := len(a.message.ToolCalls()) > 0 + return (isThinking || !isFinished) && !hasContent && !hasToolCalls } // SetMessage is used to update the underlying message. From b6185b1d9e8f0c58531c3567750d43f20274489b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 20:08:52 +0100 Subject: [PATCH 072/335] fix(chat): race condition --- internal/ui/model/ui.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index dc581d4febe19a73ae12618a44f9ccd8bdf802b8..967452e3e81aa7c92c75b02c184dd6f53aaa164a 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1279,8 +1279,10 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C cmds = append(cmds, m.loadSession(newSession.ID)) } + // Capture session ID to avoid race with main goroutine updating m.session. + sessionID := m.session.ID cmds = append(cmds, func() tea.Msg { - _, err := m.com.App.AgentCoordinator.Run(context.Background(), m.session.ID, content, attachments...) + _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...) if err != nil { isCancelErr := errors.Is(err, context.Canceled) isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied) From d5f0987bd52d458f86cdfadec0cad4920bb2ce2a Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 20:48:01 +0100 Subject: [PATCH 073/335] fix(chat): reset index and paused animations --- internal/ui/model/chat.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 76b52ce7be311656a1e546bb6f5e261333c95e0a..685d541da9f7bf5240a6bc9302098a1ed79153bb 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -71,6 +71,9 @@ func (m *Chat) Len() int { // SetMessages sets the chat messages to the provided list of message items. func (m *Chat) SetMessages(msgs ...chat.MessageItem) { + m.idInxMap = make(map[string]int) + m.pausedAnimations = make(map[string]struct{}) + items := make([]list.Item, len(msgs)) for i, msg := range msgs { m.idInxMap[msg.ID()] = i @@ -83,7 +86,7 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) { // AppendMessages appends a new message item to the chat list. func (m *Chat) AppendMessages(msgs ...chat.MessageItem) { items := make([]list.Item, len(msgs)) - indexOffset := len(m.idInxMap) + indexOffset := m.list.Len() for i, msg := range msgs { m.idInxMap[msg.ID()] = indexOffset + i items[i] = msg From c0be798cb5dfda1408a87f3a90bf8294aca6601a Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 17 Dec 2025 16:36:28 -0500 Subject: [PATCH 074/335] feat(ui): list: expose filterable items source type and return values for selection methods --- internal/ui/list/filterable.go | 12 ++++++++---- internal/ui/list/item.go | 19 +++++++++++++++++++ internal/ui/list/list.go | 21 +++++++++++++++++---- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/internal/ui/list/filterable.go b/internal/ui/list/filterable.go index c45db41da2cc6be8bd61fba57818e5a7d902f5cd..de78041e3c2666830b6f5ce695472d46448abf0f 100644 --- a/internal/ui/list/filterable.go +++ b/internal/ui/list/filterable.go @@ -70,13 +70,17 @@ func (f *FilterableList) SetFilter(q string) { f.query = q } -type filterableItems []FilterableItem +// FilterableItemsSource is a type that implements [fuzzy.Source] for filtering +// [FilterableItem]s. +type FilterableItemsSource []FilterableItem -func (f filterableItems) Len() int { +// Len returns the length of the source. +func (f FilterableItemsSource) Len() int { return len(f) } -func (f filterableItems) String(i int) string { +// String returns the string representation of the item at index i. +func (f FilterableItemsSource) String(i int) string { return f[i].Filter() } @@ -94,7 +98,7 @@ func (f *FilterableList) VisibleItems() []Item { return items } - items := filterableItems(f.items) + items := FilterableItemsSource(f.items) matches := fuzzy.FindFrom(f.query, items) matchedItems := []Item{} resultSize := len(matches) diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go index a544b85b37dedf889cdc1ecb6ae77388040907f2..62b31a696eee11b5dc11f0228d82ccfa8a0c91e5 100644 --- a/internal/ui/list/item.go +++ b/internal/ui/list/item.go @@ -1,6 +1,8 @@ package list import ( + "strings" + "github.com/charmbracelet/x/ansi" ) @@ -30,3 +32,20 @@ type MouseClickable interface { // It returns true if the event was handled, false otherwise. HandleMouseClick(btn ansi.MouseButton, x, y int) bool } + +// SpacerItem is a spacer item that adds vertical space in the list. +type SpacerItem struct { + Height int +} + +// NewSpacerItem creates a new [SpacerItem] with the specified height. +func NewSpacerItem(height int) *SpacerItem { + return &SpacerItem{ + Height: max(0, height-1), + } +} + +// Render implements the Item interface for [SpacerItem]. +func (s *SpacerItem) Render(width int) string { + return strings.Repeat("\n", s.Height) +} diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 17766cd52506132322c051fffaf33de613332315..fe136bfc7a8746ca10347e260c1389aef624656e 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -390,6 +390,7 @@ func (l *List) SelectedItemInView() bool { } // SetSelected sets the selected item index in the list. +// It returns -1 if the index is out of bounds. func (l *List) SetSelected(index int) { if index < 0 || index >= len(l.items) { l.selectedIdx = -1 @@ -415,31 +416,43 @@ func (l *List) IsSelectedLast() bool { } // SelectPrev selects the previous item in the list. -func (l *List) SelectPrev() { +// It returns whether the selection changed. +func (l *List) SelectPrev() bool { if l.selectedIdx > 0 { l.selectedIdx-- + return true } + return false } // SelectNext selects the next item in the list. -func (l *List) SelectNext() { +// It returns whether the selection changed. +func (l *List) SelectNext() bool { if l.selectedIdx < len(l.items)-1 { l.selectedIdx++ + return true } + return false } // SelectFirst selects the first item in the list. -func (l *List) SelectFirst() { +// It returns whether the selection changed. +func (l *List) SelectFirst() bool { if len(l.items) > 0 { l.selectedIdx = 0 + return true } + return false } // SelectLast selects the last item in the list. -func (l *List) SelectLast() { +// It returns whether the selection changed. +func (l *List) SelectLast() bool { if len(l.items) > 0 { l.selectedIdx = len(l.items) - 1 + return true } + return false } // SelectedItem returns the currently selected item. It may be nil if no item From 4316ef3c2f6b14d242e04d569d99d4fcea829ce8 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 18 Dec 2025 08:44:11 +0100 Subject: [PATCH 075/335] feat(chat): expandable thinking for assistant --- internal/ui/chat/assistant.go | 19 +++++++++++++++++++ internal/ui/chat/messages.go | 6 ++++++ internal/ui/model/chat.go | 7 +++++++ internal/ui/model/keys.go | 6 +++++- internal/ui/model/ui.go | 2 ++ 5 files changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 66fe97e1bc5a7d6242200835a72136c098935ab1..331167bdc13d90233f881343ce607c07e0ef700c 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -230,3 +230,22 @@ func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd { } return nil } + +// ToggleExpanded toggles the expanded state of the thinking box. +func (a *AssistantMessageItem) ToggleExpanded() { + a.thinkingExpanded = !a.thinkingExpanded + a.clearCache() +} + +// HandleMouseClick implements MouseClickable. +func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool { + if btn != ansi.MouseLeft { + return false + } + // check if the click is within the thinking box + if a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight { + a.ToggleExpanded() + return true + } + return false +} diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 3e12efefa4db1c9d78cbfe5a44374f4a918290f4..fe1e0c8f891c14f90c7e58cf8a99deb16d165765 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -26,11 +26,17 @@ type Identifiable interface { ID() string } +// Animatable is an interface for items that support animation. type Animatable interface { StartAnimation() tea.Cmd Animate(msg anim.StepMsg) tea.Cmd } +// Expandable is an interface for items that can be expanded or collapsed. +type Expandable interface { + ToggleExpanded() +} + // MessageItem represents a [message.Message] item that can be displayed in the // UI and be part of a [list.List] identifiable by a unique ID. type MessageItem interface { diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 685d541da9f7bf5240a6bc9302098a1ed79153bb..2844cb81619e99956ba5895686d82e65eef92ce7 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -250,6 +250,13 @@ func (m *Chat) GetMessageItem(id string) chat.MessageItem { return item } +// 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() + } +} + // HandleMouseDown handles mouse down events for the chat component. func (m *Chat) HandleMouseDown(x, y int) bool { if m.list.Len() == 0 { diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index d421c00ca032a97b424fafe6442a243fc98080b1..f2f3fc9106c92effe38b48dd6f664cb8617f9443 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -37,6 +37,7 @@ type KeyMap struct { End key.Binding Copy key.Binding ClearHighlight key.Binding + Expand key.Binding } Initialize struct { @@ -205,7 +206,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "clear selection"), ) - + km.Chat.Expand = key.NewBinding( + key.WithKeys("space"), + key.WithHelp("space", "expand/collapse"), + ) km.Initialize.Yes = key.NewBinding( key.WithKeys("y", "Y"), key.WithHelp("y", "yes"), diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 967452e3e81aa7c92c75b02c184dd6f53aaa164a..57a7113ae5b432436f23ec79e0d6d5cacdbb94f0 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -577,6 +577,8 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.focus = uiFocusEditor cmds = append(cmds, m.textarea.Focus()) m.chat.Blur() + case key.Matches(msg, m.keyMap.Chat.Expand): + m.chat.ToggleExpandedSelectedItem() case key.Matches(msg, m.keyMap.Chat.Up): if cmd := m.chat.ScrollBy(-1); cmd != nil { cmds = append(cmds, cmd) From 0896789b516e50c4a13c2ddc01a02fe4466c4bd8 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 18 Dec 2025 12:16:38 +0100 Subject: [PATCH 076/335] refactor(chat): initial setup for tool calls --- internal/ui/chat/bash.go | 72 ++++++++ internal/ui/chat/messages.go | 24 +++ internal/ui/chat/tools.go | 329 +++++++++++++++++++++++++++++++++++ internal/ui/styles/styles.go | 10 +- 4 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 internal/ui/chat/bash.go create mode 100644 internal/ui/chat/tools.go diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go new file mode 100644 index 0000000000000000000000000000000000000000..e933f319ff1f0b832d1af02524e8914927c66c0d --- /dev/null +++ b/internal/ui/chat/bash.go @@ -0,0 +1,72 @@ +package chat + +import ( + "encoding/json" + "strings" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// BashToolRenderer renders a bash tool call. +func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + const toolName = "Bash" + if !opts.ToolCall.Finished && !opts.Canceled { + return pendingTool(sty, toolName, opts.Anim) + } + + var params tools.BashParams + var cmd string + err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + if err != nil { + cmd = "failed to parse command" + } else { + cmd = strings.ReplaceAll(params.Command, "\n", " ") + cmd = strings.ReplaceAll(cmd, "\t", " ") + } + + toolParams := []string{ + cmd, + } + + if params.RunInBackground { + toolParams = append(toolParams, "background", "true") + } + + header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...) + earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth) + + // If this is OK that means that the tool is not done yet or it was canceled + if ok { + return strings.Join([]string{header, "", earlyStateContent}, "\n") + } + + if opts.Result == nil { + // We should not get here! + return header + } + + var meta tools.BashResponseMetadata + err = json.Unmarshal([]byte(opts.Result.Metadata), &meta) + + var output string + if err != nil { + output = "failed to parse output" + } + output = meta.Output + if output == "" && opts.Result.Content != tools.BashNoOutput { + output = opts.Result.Content + } + + if output == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + + output = sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded)) + + return strings.Join([]string{header, "", output}, "\n") +} diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index fe1e0c8f891c14f90c7e58cf8a99deb16d165765..9c925e0719436b4289cc2984f740df64c49c68ec 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -8,6 +8,7 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/list" @@ -164,6 +165,29 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s if shouldRenderAssistantMessage(msg) { items = append(items, NewAssistantMessageItem(sty, msg)) } + for _, tc := range msg.ToolCalls() { + var result *message.ToolResult + if tr, ok := toolResults[tc.ID]; ok { + result = &tr + } + renderFunc := DefaultToolRenderer + // we only do full width for diffs (as far as I know) + cappedWidth := true + switch tc.Name { + case tools.BashToolName: + renderFunc = BashToolRenderer + } + + items = append(items, NewToolMessageItem( + sty, + renderFunc, + tc, + result, + msg.FinishReason() == message.FinishReasonCanceled, + cappedWidth, + )) + + } return items } return []MessageItem{} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go new file mode 100644 index 0000000000000000000000000000000000000000..e35934400f374feb22941020e0cf7f389eab8254 --- /dev/null +++ b/internal/ui/chat/tools.go @@ -0,0 +1,329 @@ +package chat + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// responseContextHeight limits the number of lines displayed in tool output. +const responseContextHeight = 10 + +// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body +const toolBodyLeftPaddingTotal = 2 + +// ToolStatus represents the current state of a tool call. +type ToolStatus int + +const ( + ToolStatusAwaitingPermission ToolStatus = iota + ToolStatusRunning + ToolStatusSuccess + ToolStatusError + ToolStatusCanceled +) + +// ToolRenderOpts contains the data needed to render a tool call. +type ToolRenderOpts struct { + ToolCall message.ToolCall + Result *message.ToolResult + Canceled bool + Anim *anim.Anim + Expanded bool + IsSpinning bool + PermissionRequested bool + PermissionGranted bool +} + +// Status returns the current status of the tool call. +func (opts *ToolRenderOpts) Status() ToolStatus { + if opts.Canceled { + return ToolStatusCanceled + } + if opts.Result != nil { + if opts.Result.IsError { + return ToolStatusError + } + return ToolStatusSuccess + } + if opts.PermissionRequested && !opts.PermissionGranted { + return ToolStatusAwaitingPermission + } + return ToolStatusRunning +} + +// ToolRenderFunc is a function that renders a tool call to a string. +type ToolRenderFunc func(sty *styles.Styles, width int, t *ToolRenderOpts) string + +// DefaultToolRenderer is a placeholder renderer for tools without a custom renderer. +func DefaultToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name +} + +// ToolMessageItem represents a tool call message that can be displayed in the UI. +type ToolMessageItem struct { + *highlightableMessageItem + *cachedMessageItem + *focusableMessageItem + + renderFunc ToolRenderFunc + toolCall message.ToolCall + result *message.ToolResult + canceled bool + // we use this so we can efficiently cache + // tools that have a capped width (e.x bash.. and others) + hasCappedWidth bool + + sty *styles.Styles + anim *anim.Anim + expanded bool +} + +// NewToolMessageItem creates a new tool message item with the given renderFunc. +func NewToolMessageItem( + sty *styles.Styles, + renderFunc ToolRenderFunc, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, + hasCappedWidth bool, +) *ToolMessageItem { + t := &ToolMessageItem{ + highlightableMessageItem: defaultHighlighter(sty), + cachedMessageItem: &cachedMessageItem{}, + focusableMessageItem: &focusableMessageItem{}, + sty: sty, + renderFunc: renderFunc, + toolCall: toolCall, + result: result, + canceled: canceled, + hasCappedWidth: hasCappedWidth, + } + t.anim = anim.New(anim.Settings{ + ID: toolCall.ID, + Size: 15, + GradColorA: sty.Primary, + GradColorB: sty.Secondary, + LabelColor: sty.FgBase, + CycleColors: true, + }) + return t +} + +// ID returns the unique identifier for this tool message item. +func (t *ToolMessageItem) ID() string { + return t.toolCall.ID +} + +// StartAnimation starts the assistant message animation if it should be spinning. +func (t *ToolMessageItem) StartAnimation() tea.Cmd { + if !t.isSpinning() { + return nil + } + return t.anim.Start() +} + +// Animate progresses the assistant message animation if it should be spinning. +func (t *ToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { + if !t.isSpinning() { + return nil + } + return t.anim.Animate(msg) +} + +// Render renders the tool message item at the given width. +func (t *ToolMessageItem) Render(width int) string { + toolItemWidth := width - messageLeftPaddingTotal + if t.hasCappedWidth { + toolItemWidth = cappedMessageWidth(width) + } + style := t.sty.Chat.Message.ToolCallBlurred + if t.focused { + style = t.sty.Chat.Message.ToolCallFocused + } + + content, height, ok := t.getCachedRender(toolItemWidth) + // if we are spinning or there is no cache rerender + if !ok || t.isSpinning() { + content = t.renderFunc(t.sty, toolItemWidth, &ToolRenderOpts{ + ToolCall: t.toolCall, + Result: t.result, + Canceled: t.canceled, + Anim: t.anim, + Expanded: t.expanded, + IsSpinning: t.isSpinning(), + }) + height = lipgloss.Height(content) + // cache the rendered content + t.setCachedRender(content, toolItemWidth, height) + } + + highlightedContent := t.renderHighlighted(content, toolItemWidth, height) + return style.Render(highlightedContent) +} + +// isSpinning returns true if the tool should show animation. +func (t *ToolMessageItem) isSpinning() bool { + return !t.toolCall.Finished && !t.canceled +} + +// ToggleExpanded toggles the expanded state of the thinking box. +func (t *ToolMessageItem) ToggleExpanded() { + t.expanded = !t.expanded + t.clearCache() +} + +// HandleMouseClick implements MouseClickable. +func (t *ToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool { + if btn != ansi.MouseLeft { + return false + } + t.ToggleExpanded() + return true +} + +// pendingTool renders a tool that is still in progress with an animation. +func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string { + icon := sty.Tool.IconPending.Render() + toolName := sty.Tool.NameNormal.Render(name) + + var animView string + if anim != nil { + animView = anim.Render() + } + + return fmt.Sprintf("%s %s %s", icon, toolName, animView) +} + +// toolEarlyStateContent handles error/cancelled/pending states before content rendering. +// Returns the rendered output and true if early state was handled. +func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) { + var msg string + switch opts.Status() { + case ToolStatusError: + msg = toolErrorContent(sty, opts.Result, width) + case ToolStatusCanceled: + msg = sty.Tool.StateCancelled.Render("Canceled.") + case ToolStatusAwaitingPermission: + msg = sty.Tool.StateWaiting.Render("Requesting permission...") + case ToolStatusRunning: + msg = sty.Tool.StateWaiting.Render("Waiting for tool response...") + default: + return "", false + } + return msg, true +} + +// toolErrorContent formats an error message with ERROR tag. +func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string { + if result == nil { + return "" + } + errContent := strings.ReplaceAll(result.Content, "\n", " ") + errTag := sty.Tool.ErrorTag.Render("ERROR") + tagWidth := lipgloss.Width(errTag) + errContent = ansi.Truncate(errContent, width-tagWidth-3, "…") + return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent)) +} + +// toolIcon returns the status icon for a tool call. +// toolIcon returns the status icon for a tool call based on its status. +func toolIcon(sty *styles.Styles, status ToolStatus) string { + switch status { + case ToolStatusSuccess: + return sty.Tool.IconSuccess.String() + case ToolStatusError: + return sty.Tool.IconError.String() + case ToolStatusCanceled: + return sty.Tool.IconCancelled.String() + default: + return sty.Tool.IconPending.String() + } +} + +// toolParamList formats parameters as "main (key=value, ...)" with truncation. +// toolParamList formats tool parameters as "main (key=value, ...)" with truncation. +func toolParamList(sty *styles.Styles, params []string, width int) string { + // minSpaceForMainParam is the min space required for the main param + // if this is less that the value set we will only show the main param nothing else + const minSpaceForMainParam = 30 + if len(params) == 0 { + return "" + } + + mainParam := params[0] + + // Build key=value pairs from remaining params (consecutive key, value pairs). + var kvPairs []string + for i := 1; i+1 < len(params); i += 2 { + if params[i+1] != "" { + kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1])) + } + } + + // Try to include key=value pairs if there's enough space. + output := mainParam + if len(kvPairs) > 0 { + partsStr := strings.Join(kvPairs, ", ") + if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam { + output = fmt.Sprintf("%s (%s)", mainParam, partsStr) + } + } + + if width >= 0 { + output = ansi.Truncate(output, width, "…") + } + return sty.Tool.ParamMain.Render(output) +} + +// toolHeader builds the tool header line: "● ToolName params..." +func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, params ...string) string { + icon := toolIcon(sty, status) + toolName := sty.Tool.NameNested.Render(name) + prefix := fmt.Sprintf("%s %s ", icon, toolName) + prefixWidth := lipgloss.Width(prefix) + remainingWidth := width - prefixWidth + paramsStr := toolParamList(sty, params, remainingWidth) + return prefix + paramsStr +} + +// toolOutputPlainContent renders plain text with optional expansion support. +func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\t", " ") + content = strings.TrimSpace(content) + lines := strings.Split(content, "\n") + + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) // Show all + } + + var out []string + for i, ln := range lines { + if i >= maxLines { + break + } + ln = " " + ln + if lipgloss.Width(ln) > width { + ln = ansi.Truncate(ln, width, "…") + } + out = append(out, sty.Tool.ContentLine.Width(width).Render(ln)) + } + + wasTruncated := len(lines) > responseContextHeight + + if !expanded && wasTruncated { + out = append(out, sty.Tool.ContentTruncation. + Width(width). + Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight))) + } + + return strings.Join(out, "\n") +} diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index d6f96474c7daf3397ebebb9b819b2388790d7a95..21ace6a1e588f97a4be586cc01c5ecf3f0a88984 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -26,6 +26,8 @@ const ( DocumentIcon string = "🖼" ModelIcon string = "◇" + ArrowRightIcon string = "→" + ToolPending string = "●" ToolSuccess string = "✓" ToolError string = "×" @@ -34,6 +36,10 @@ const ( BorderThick string = "▌" SectionSeparator string = "─" + + TodoCompletedIcon string = "✓" + TodoPendingIcon string = "•" + TodoInProgressIcon string = "→" ) const ( @@ -227,7 +233,7 @@ type Styles struct { ContentTruncation lipgloss.Style // Truncation message "… (N lines)" ContentCodeLine lipgloss.Style // Code line with background and width ContentCodeBg color.Color // Background color for syntax highlighting - BodyPadding lipgloss.Style // Body content padding (PaddingLeft(2)) + Body lipgloss.Style // Body content padding (PaddingLeft(2)) // Deprecated - kept for backward compatibility ContentBg lipgloss.Style // Content background @@ -956,7 +962,7 @@ func DefaultStyles() Styles { s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter) s.Tool.ContentCodeLine = s.Base.Background(bgBaseLighter) s.Tool.ContentCodeBg = bgBase - s.Tool.BodyPadding = base.PaddingLeft(2) + s.Tool.Body = base.PaddingLeft(2) // Deprecated - kept for backward compatibility s.Tool.ContentBg = s.Muted.Background(bgBaseLighter) From 462cdf644189170903c3a51c4ee0363263d1d026 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 18 Dec 2025 13:00:11 +0100 Subject: [PATCH 077/335] refactor(chat): hook up events for tools --- internal/ui/chat/assistant.go | 2 +- internal/ui/chat/bash.go | 7 +++ internal/ui/chat/messages.go | 32 ++++++---- internal/ui/chat/tools.go | 54 ++++++++++++++--- internal/ui/model/chat.go | 8 +++ internal/ui/model/ui.go | 111 ++++++++++++++++++++++++++++------ 6 files changed, 174 insertions(+), 40 deletions(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 331167bdc13d90233f881343ce607c07e0ef700c..de1e6dceddfd4f2939190d6d4bde46ce3ff8fb55 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -167,7 +167,7 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string var footer string // if thinking is done add the thought for footer - if !a.message.IsThinking() { + if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 { duration := a.message.ThinkingDuration() if duration.String() != "0s" { footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") + diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index e933f319ff1f0b832d1af02524e8914927c66c0d..539ed23497ecf8a0095beb0562f4040bacb39349 100644 --- a/internal/ui/chat/bash.go +++ b/internal/ui/chat/bash.go @@ -27,6 +27,8 @@ func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) strin cmd = strings.ReplaceAll(cmd, "\t", " ") } + // TODO: if the tool is being run in the background use the background job renderer + toolParams := []string{ cmd, } @@ -36,6 +38,11 @@ func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) strin } header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...) + + if opts.Nested { + return header + } + earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth) // If this is OK that means that the tool is not done yet or it was canceled diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 9c925e0719436b4289cc2984f740df64c49c68ec..af89bc5e7cbb9be861fc22dbcc6a1141c671fb93 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -170,29 +170,37 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s if tr, ok := toolResults[tc.ID]; ok { result = &tr } - renderFunc := DefaultToolRenderer - // we only do full width for diffs (as far as I know) - cappedWidth := true - switch tc.Name { - case tools.BashToolName: - renderFunc = BashToolRenderer - } - - items = append(items, NewToolMessageItem( + items = append(items, GetToolMessageItem( sty, - renderFunc, tc, result, msg.FinishReason() == message.FinishReasonCanceled, - cappedWidth, )) - } return items } return []MessageItem{} } +func GetToolMessageItem(sty *styles.Styles, tc message.ToolCall, result *message.ToolResult, canceled bool) MessageItem { + renderFunc := DefaultToolRenderer + // we only do full width for diffs (as far as I know) + cappedWidth := true + switch tc.Name { + case tools.BashToolName: + renderFunc = BashToolRenderer + } + + return NewToolMessageItem( + sty, + renderFunc, + tc, + result, + canceled, + cappedWidth, + ) +} + // shouldRenderAssistantMessage determines if an assistant message should be rendered // // In some cases the assistant message only has tools so we do not want to render an diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index e35934400f374feb22941020e0cf7f389eab8254..b643ef2805708ade2fbc209bb6b1b9a29ade97e4 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -36,6 +36,7 @@ type ToolRenderOpts struct { Canceled bool Anim *anim.Anim Expanded bool + Nested bool IsSpinning bool PermissionRequested bool PermissionGranted bool @@ -72,10 +73,12 @@ type ToolMessageItem struct { *cachedMessageItem *focusableMessageItem - renderFunc ToolRenderFunc - toolCall message.ToolCall - result *message.ToolResult - canceled bool + renderFunc ToolRenderFunc + toolCall message.ToolCall + result *message.ToolResult + canceled bool + permissionRequested bool + permissionGranted bool // we use this so we can efficiently cache // tools that have a capped width (e.x bash.. and others) hasCappedWidth bool @@ -152,12 +155,14 @@ func (t *ToolMessageItem) Render(width int) string { // if we are spinning or there is no cache rerender if !ok || t.isSpinning() { content = t.renderFunc(t.sty, toolItemWidth, &ToolRenderOpts{ - ToolCall: t.toolCall, - Result: t.result, - Canceled: t.canceled, - Anim: t.anim, - Expanded: t.expanded, - IsSpinning: t.isSpinning(), + ToolCall: t.toolCall, + Result: t.result, + Canceled: t.canceled, + Anim: t.anim, + Expanded: t.expanded, + PermissionRequested: t.permissionRequested, + PermissionGranted: t.permissionGranted, + IsSpinning: t.isSpinning(), }) height = lipgloss.Height(content) // cache the rendered content @@ -168,6 +173,35 @@ func (t *ToolMessageItem) Render(width int) string { return style.Render(highlightedContent) } +// ToolCall returns the tool call associated with this message item. +func (t *ToolMessageItem) ToolCall() message.ToolCall { + return t.toolCall +} + +// SetToolCall sets the tool call associated with this message item. +func (t *ToolMessageItem) SetToolCall(tc message.ToolCall) { + t.toolCall = tc + t.clearCache() +} + +// SetResult sets the tool result associated with this message item. +func (t *ToolMessageItem) SetResult(res *message.ToolResult) { + t.result = res + t.clearCache() +} + +// SetPermissionRequested sets whether permission has been requested for this tool call. +func (t *ToolMessageItem) SetPermissionRequested(requested bool) { + t.permissionRequested = requested + t.clearCache() +} + +// SetPermissionGranted sets whether permission has been granted for this tool call. +func (t *ToolMessageItem) SetPermissionGranted(granted bool) { + t.permissionGranted = granted + t.clearCache() +} + // isSpinning returns true if the tool should show animation. func (t *ToolMessageItem) isSpinning() bool { return !t.toolCall.Finished && !t.canceled diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 2844cb81619e99956ba5895686d82e65eef92ce7..7c19f4d6c49e7f33c57979a0f9f4a2230a780670 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -237,6 +237,14 @@ func (m *Chat) SelectLastInView() { m.list.SelectLastInView() } +// ClearMessages removes all messages from the chat list. +func (m *Chat) ClearMessages() { + m.idInxMap = make(map[string]int) + m.pausedAnimations = make(map[string]struct{}) + m.list.SetItems() + m.ClearMouse() +} + // GetMessageItem returns the message item at the given id. func (m *Chat) GetMessageItem(id string) chat.MessageItem { idx, ok := m.idInxMap[id] diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 57a7113ae5b432436f23ec79e0d6d5cacdbb94f0..e1cfcf0e66eb4c407678e9b65b080827989aea56 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -208,6 +208,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case pubsub.Event[message.Message]: + // TODO: handle nested messages for agentic tools if m.session == nil || msg.Payload.SessionID != m.session.ID { break } @@ -217,8 +218,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case pubsub.UpdatedEvent: cmds = append(cmds, m.updateSessionMessage(msg.Payload)) } - // TODO: Finish implementing me - // cmds = append(cmds, m.setMessageEvents(msg.Payload)) case pubsub.Event[history.File]: cmds = append(cmds, m.handleFileEvent(msg.Payload)) case pubsub.Event[app.LSPEvent]: @@ -403,35 +402,81 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { } // appendSessionMessage appends a new message to the current session in the chat +// if the message is a tool result it will update the corresponding tool call message func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { - items := chat.GetMessageItems(m.com.Styles, &msg, nil) var cmds []tea.Cmd - for _, item := range items { - if animatable, ok := item.(chat.Animatable); ok { - if cmd := animatable.StartAnimation(); cmd != nil { - cmds = append(cmds, cmd) + switch msg.Role { + case message.User, message.Assistant: + items := chat.GetMessageItems(m.com.Styles, &msg, nil) + for _, item := range items { + if animatable, ok := item.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + m.chat.AppendMessages(items...) + if cmd := m.chat.ScrollToBottom(); cmd != nil { + cmds = append(cmds, cmd) + } + case message.Tool: + for _, tr := range msg.ToolResults() { + toolItem := m.chat.GetMessageItem(tr.ToolCallID) + if toolItem == nil { + // we should have an item! + continue + } + if toolMsgItem, ok := toolItem.(*chat.ToolMessageItem); ok { + toolMsgItem.SetResult(&tr) } } - } - m.chat.AppendMessages(items...) - if cmd := m.chat.ScrollToBottom(); cmd != nil { - cmds = append(cmds, cmd) } return tea.Batch(cmds...) } // updateSessionMessage updates an existing message in the current session in the chat -// INFO: currently only updates the assistant when I add tools this will get a bit more complex +// when an assistant message is updated it may include updated tool calls as well +// that is why we need to handle creating/updating each tool call message too func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { + var cmds []tea.Cmd existingItem := m.chat.GetMessageItem(msg.ID) - switch msg.Role { - case message.Assistant: - if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok { - assistantItem.SetMessage(&msg) + if existingItem == nil || msg.Role != message.Assistant { + return nil + } + + if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok { + assistantItem.SetMessage(&msg) + } + + var items []chat.MessageItem + for _, tc := range msg.ToolCalls() { + existingToolItem := m.chat.GetMessageItem(tc.ID) + if toolItem, ok := existingToolItem.(*chat.ToolMessageItem); ok { + existingToolCall := toolItem.ToolCall() + // only update if finished state changed or input changed + // to avoid clearing the cache + if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input { + toolItem.SetToolCall(tc) + } + } + if existingToolItem == nil { + items = append(items, chat.GetToolMessageItem(m.com.Styles, tc, nil, false)) } } - return nil + for _, item := range items { + if animatable, ok := item.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + m.chat.AppendMessages(items...) + if cmd := m.chat.ScrollToBottom(); cmd != nil { + cmds = append(cmds, cmd) + } + + return tea.Batch(cmds...) } func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { @@ -498,6 +543,13 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { case dialog.SwitchSessionsMsg: cmds = append(cmds, m.listSessions) m.dialog.CloseDialog(dialog.CommandsID) + case dialog.NewSessionsMsg: + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) + break + } + m.newSession() + m.dialog.CloseDialog(dialog.CommandsID) case dialog.CompactMsg: err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) if err != nil { @@ -548,6 +600,15 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.randomizePlaceholders() return m.sendMessage(value, attachments) + case key.Matches(msg, m.keyMap.Chat.NewSession): + if m.session == nil || m.session.ID == "" { + break + } + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) + break + } + m.newSession() case key.Matches(msg, m.keyMap.Tab): m.focus = uiFocusMain m.textarea.Blur() @@ -1362,6 +1423,22 @@ func (m *UI) listSessions() tea.Msg { return listSessionsMsg{sessions: allSessions} } +// newSession clears the current session state and prepares for a new session. +// The actual session creation happens when the user sends their first message. +func (m *UI) newSession() { + if m.session == nil || m.session.ID == "" { + return + } + + m.session = nil + m.sessionFiles = nil + m.state = uiLanding + m.focus = uiFocusEditor + m.textarea.Focus() + m.chat.Blur() + m.chat.ClearMessages() +} + // handlePasteMsg handles a paste message. func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { if m.focus != uiFocusEditor { From 14ff2243a2f79c3925bc56247d7234aab1d332cd Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 18 Dec 2025 13:08:50 +0100 Subject: [PATCH 078/335] chore(chat): small improvements --- internal/ui/chat/messages.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index af89bc5e7cbb9be861fc22dbcc6a1141c671fb93..9bf977d2e2f6fbd960e57a7e5d77fedb28b5b54b 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -182,18 +182,25 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s return []MessageItem{} } -func GetToolMessageItem(sty *styles.Styles, tc message.ToolCall, result *message.ToolResult, canceled bool) MessageItem { +// GetToolRenderer returns the appropriate ToolRenderFunc for a given tool call. +// this should be used for nested tools as well. +func GetToolRenderer(tc message.ToolCall) ToolRenderFunc { renderFunc := DefaultToolRenderer - // we only do full width for diffs (as far as I know) - cappedWidth := true switch tc.Name { case tools.BashToolName: renderFunc = BashToolRenderer } + return renderFunc +} + +// GetToolMessageItem creates a MessageItem for a tool call and its result. +func GetToolMessageItem(sty *styles.Styles, tc message.ToolCall, result *message.ToolResult, canceled bool) MessageItem { + // we only do full width for diffs (as far as I know) + cappedWidth := tc.Name != tools.EditToolName && tc.Name != tools.MultiEditToolName return NewToolMessageItem( sty, - renderFunc, + GetToolRenderer(tc), tc, result, canceled, From 03beedc2002cb9f909568ec3c32775fca0faf023 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 18 Dec 2025 14:33:28 +0100 Subject: [PATCH 079/335] fix(chat): do not mark tools with results as canceled --- internal/ui/chat/tools.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index b643ef2805708ade2fbc209bb6b1b9a29ade97e4..5d6987c604aac42090659dd35d3da77d9bfe9a2e 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -44,7 +44,7 @@ type ToolRenderOpts struct { // Status returns the current status of the tool call. func (opts *ToolRenderOpts) Status() ToolStatus { - if opts.Canceled { + if opts.Canceled && opts.Result == nil { return ToolStatusCanceled } if opts.Result != nil { From b0dd9830ad278754699406aff3c44b97d01fb8be Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 18 Dec 2025 17:30:55 +0100 Subject: [PATCH 080/335] chore(chat): rename funcs --- internal/ui/model/chat.go | 16 +++++++------- internal/ui/model/ui.go | 44 +++++++++++++++++++-------------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 2844cb81619e99956ba5895686d82e65eef92ce7..7d6ad220d27426dc44fb3d3a277390b1e2f8b24a 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -169,30 +169,30 @@ func (m *Chat) Blur() { m.list.Blur() } -// ScrollToTop scrolls the chat view to the top and returns a command to restart +// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart // any paused animations that are now visible. -func (m *Chat) ScrollToTop() tea.Cmd { +func (m *Chat) ScrollToTopAndAnimate() tea.Cmd { m.list.ScrollToTop() return m.RestartPausedVisibleAnimations() } -// ScrollToBottom scrolls the chat view to the bottom and returns a command to +// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to // restart any paused animations that are now visible. -func (m *Chat) ScrollToBottom() tea.Cmd { +func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd { m.list.ScrollToBottom() return m.RestartPausedVisibleAnimations() } -// ScrollBy scrolls the chat view by the given number of line deltas and returns +// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns // a command to restart any paused animations that are now visible. -func (m *Chat) ScrollBy(lines int) tea.Cmd { +func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd { m.list.ScrollBy(lines) return m.RestartPausedVisibleAnimations() } -// ScrollToSelected scrolls the chat view to the selected item and returns a +// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a // command to restart any paused animations that are now visible. -func (m *Chat) ScrollToSelected() tea.Cmd { +func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd { m.list.ScrollToSelected() return m.RestartPausedVisibleAnimations() } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 57a7113ae5b432436f23ec79e0d6d5cacdbb94f0..b5e4a9b53dd90a685329174fe497e22ab04700f2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -268,22 +268,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.state { case uiChat: if msg.Y <= 0 { - if cmd := m.chat.ScrollBy(-1); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { m.chat.SelectPrev() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } } } else if msg.Y >= m.chat.Height()-1 { - if cmd := m.chat.ScrollBy(1); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { m.chat.SelectNext() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } } @@ -310,22 +310,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case uiChat: switch msg.Button { case tea.MouseWheelUp: - if cmd := m.chat.ScrollBy(-5); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { m.chat.SelectPrev() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } } case tea.MouseWheelDown: - if cmd := m.chat.ScrollBy(5); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { m.chat.SelectNext() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } } @@ -395,7 +395,7 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { } m.chat.SetMessages(items...) - if cmd := m.chat.ScrollToBottom(); cmd != nil { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } m.chat.SelectLast() @@ -414,7 +414,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { } } m.chat.AppendMessages(items...) - if cmd := m.chat.ScrollToBottom(); cmd != nil { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } return tea.Batch(cmds...) @@ -580,62 +580,62 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { case key.Matches(msg, m.keyMap.Chat.Expand): m.chat.ToggleExpandedSelectedItem() case key.Matches(msg, m.keyMap.Chat.Up): - if cmd := m.chat.ScrollBy(-1); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { m.chat.SelectPrev() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } } case key.Matches(msg, m.keyMap.Chat.Down): - if cmd := m.chat.ScrollBy(1); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { m.chat.SelectNext() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } } case key.Matches(msg, m.keyMap.Chat.UpOneItem): m.chat.SelectPrev() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } case key.Matches(msg, m.keyMap.Chat.DownOneItem): m.chat.SelectNext() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } case key.Matches(msg, m.keyMap.Chat.HalfPageUp): - if cmd := m.chat.ScrollBy(-m.chat.Height() / 2); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil { cmds = append(cmds, cmd) } m.chat.SelectFirstInView() case key.Matches(msg, m.keyMap.Chat.HalfPageDown): - if cmd := m.chat.ScrollBy(m.chat.Height() / 2); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil { cmds = append(cmds, cmd) } m.chat.SelectLastInView() case key.Matches(msg, m.keyMap.Chat.PageUp): - if cmd := m.chat.ScrollBy(-m.chat.Height()); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil { cmds = append(cmds, cmd) } m.chat.SelectFirstInView() case key.Matches(msg, m.keyMap.Chat.PageDown): - if cmd := m.chat.ScrollBy(m.chat.Height()); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil { cmds = append(cmds, cmd) } m.chat.SelectLastInView() case key.Matches(msg, m.keyMap.Chat.Home): - if cmd := m.chat.ScrollToTop(); cmd != nil { + if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } m.chat.SelectFirst() case key.Matches(msg, m.keyMap.Chat.End): - if cmd := m.chat.ScrollToBottom(); cmd != nil { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } m.chat.SelectLast() From f14253f1085fe7d5de9559805cddc6e6710835ab Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 11:37:17 -0500 Subject: [PATCH 081/335] fix(ui): dialog: clarify session age display --- internal/ui/dialog/sessions_item.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index 080de32dba34a8fa50f2b40db2d8335fdcb911d9..9860581eb3c967154b09b42217e2283192962373 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -86,13 +86,20 @@ func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, w } var ageLen int + var right string + lineWidth := width if updatedAt > 0 { ageLen = lipgloss.Width(age) + lineWidth -= ageLen } - title = ansi.Truncate(title, max(0, width-ageLen), "…") + title = ansi.Truncate(title, max(0, lineWidth), "…") titleLen := lipgloss.Width(title) - right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age) + + if updatedAt > 0 { + right = lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age) + } + content := title if matches := len(m.MatchedIndexes); matches > 0 { var lastPos int From 2cccd515a848a5df0d9dffea3c3f5f037752c07b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 13:24:59 -0500 Subject: [PATCH 082/335] refactor(chat): rename GetMessageItems to ExtractMessageItems and GetToolRenderer to ToolRenderer --- internal/ui/chat/messages.go | 33 +++++++++------------------------ internal/ui/chat/tools.go | 7 ++++--- internal/ui/model/chat.go | 4 ++-- internal/ui/model/ui.go | 12 ++++++------ 4 files changed, 21 insertions(+), 35 deletions(-) diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 9bf977d2e2f6fbd960e57a7e5d77fedb28b5b54b..000847a192a106f45aa6836281e50bf89426f20f 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -151,12 +151,12 @@ func cappedMessageWidth(availableWidth int) int { return min(availableWidth-messageLeftPaddingTotal, maxTextWidth) } -// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns -// all parts of the message as [MessageItem]s. +// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It +// returns all parts of the message as [MessageItem]s. // // For assistant messages with tool calls, pass a toolResults map to link results. // Use BuildToolResultMap to create this map from all messages in a session. -func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { +func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { switch msg.Role { case message.User: return []MessageItem{NewUserMessageItem(sty, msg)} @@ -170,7 +170,7 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s if tr, ok := toolResults[tc.ID]; ok { result = &tr } - items = append(items, GetToolMessageItem( + items = append(items, NewToolMessageItem( sty, tc, result, @@ -182,30 +182,15 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s return []MessageItem{} } -// GetToolRenderer returns the appropriate ToolRenderFunc for a given tool call. +// ToolRenderer returns the appropriate [ToolRenderFunc] for a given tool call. // this should be used for nested tools as well. -func GetToolRenderer(tc message.ToolCall) ToolRenderFunc { - renderFunc := DefaultToolRenderer +func ToolRenderer(tc message.ToolCall) ToolRenderFunc { switch tc.Name { case tools.BashToolName: - renderFunc = BashToolRenderer + return BashToolRenderer + default: + return DefaultToolRenderer } - return renderFunc -} - -// GetToolMessageItem creates a MessageItem for a tool call and its result. -func GetToolMessageItem(sty *styles.Styles, tc message.ToolCall, result *message.ToolResult, canceled bool) MessageItem { - // we only do full width for diffs (as far as I know) - cappedWidth := tc.Name != tools.EditToolName && tc.Name != tools.MultiEditToolName - - return NewToolMessageItem( - sty, - GetToolRenderer(tc), - tc, - result, - canceled, - cappedWidth, - ) } // shouldRenderAssistantMessage determines if an assistant message should be rendered diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 5d6987c604aac42090659dd35d3da77d9bfe9a2e..1847ac72287af31b7a0026c2ba60f85366a19e0a 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -6,6 +6,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/styles" @@ -91,18 +92,18 @@ type ToolMessageItem struct { // NewToolMessageItem creates a new tool message item with the given renderFunc. func NewToolMessageItem( sty *styles.Styles, - renderFunc ToolRenderFunc, toolCall message.ToolCall, result *message.ToolResult, canceled bool, - hasCappedWidth bool, ) *ToolMessageItem { + // we only do full width for diffs (as far as I know) + hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName t := &ToolMessageItem{ highlightableMessageItem: defaultHighlighter(sty), cachedMessageItem: &cachedMessageItem{}, focusableMessageItem: &focusableMessageItem{}, sty: sty, - renderFunc: renderFunc, + renderFunc: ToolRenderer(toolCall), toolCall: toolCall, result: result, canceled: canceled, diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index f506d469b6b90292af6373d20f2a296fa7d0ac6a..94a999cd75b5e3853cb173c2f287b0dfba3513f7 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -245,8 +245,8 @@ func (m *Chat) ClearMessages() { m.ClearMouse() } -// GetMessageItem returns the message item at the given id. -func (m *Chat) GetMessageItem(id string) chat.MessageItem { +// MessageItem returns the message item with the given ID, or nil if not found. +func (m *Chat) MessageItem(id string) chat.MessageItem { idx, ok := m.idInxMap[id] if !ok { return nil diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 5fe8eeb3689a6a185c0e2cd6af7a1758120a5178..2dab87ba0de0eac8ed365938c7abc8395785c9b2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -380,7 +380,7 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { // Add messages to chat with linked tool results items := make([]chat.MessageItem, 0, len(msgs)*2) for _, msg := range msgPtrs { - items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...) + items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...) } // If the user switches between sessions while the agent is working we want @@ -407,7 +407,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { var cmds []tea.Cmd switch msg.Role { case message.User, message.Assistant: - items := chat.GetMessageItems(m.com.Styles, &msg, nil) + items := chat.ExtractMessageItems(m.com.Styles, &msg, nil) for _, item := range items { if animatable, ok := item.(chat.Animatable); ok { if cmd := animatable.StartAnimation(); cmd != nil { @@ -421,7 +421,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { } case message.Tool: for _, tr := range msg.ToolResults() { - toolItem := m.chat.GetMessageItem(tr.ToolCallID) + toolItem := m.chat.MessageItem(tr.ToolCallID) if toolItem == nil { // we should have an item! continue @@ -439,7 +439,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { // that is why we need to handle creating/updating each tool call message too func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { var cmds []tea.Cmd - existingItem := m.chat.GetMessageItem(msg.ID) + existingItem := m.chat.MessageItem(msg.ID) if existingItem == nil || msg.Role != message.Assistant { return nil } @@ -450,7 +450,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { var items []chat.MessageItem for _, tc := range msg.ToolCalls() { - existingToolItem := m.chat.GetMessageItem(tc.ID) + existingToolItem := m.chat.MessageItem(tc.ID) if toolItem, ok := existingToolItem.(*chat.ToolMessageItem); ok { existingToolCall := toolItem.ToolCall() // only update if finished state changed or input changed @@ -460,7 +460,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { } } if existingToolItem == nil { - items = append(items, chat.GetToolMessageItem(m.com.Styles, tc, nil, false)) + items = append(items, chat.NewToolMessageItem(m.com.Styles, tc, nil, false)) } } From 41b819f5204422eaf842d04454c4a1609ef07ae2 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 13:54:24 -0500 Subject: [PATCH 083/335] refactor(ui): chat: abstract tool message rendering --- internal/ui/chat/bash.go | 33 ++++++++++- internal/ui/chat/messages.go | 12 ---- internal/ui/chat/tools.go | 106 ++++++++++++++++++++++++++--------- internal/ui/model/ui.go | 4 +- 4 files changed, 113 insertions(+), 42 deletions(-) diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index 539ed23497ecf8a0095beb0562f4040bacb39349..c57d8e8d8a3ba0f567373a81707b6fdc54166fa6 100644 --- a/internal/ui/chat/bash.go +++ b/internal/ui/chat/bash.go @@ -5,11 +5,40 @@ import ( "strings" "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/styles" ) -// BashToolRenderer renders a bash tool call. -func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string { +// BashToolMessageItem is a message item that represents a bash tool call. +type BashToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*BashToolMessageItem)(nil) + +// NewBashToolMessageItem creates a new [BashToolMessageItem]. +func NewBashToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem( + sty, + toolCall, + result, + &BashToolRenderContext{}, + canceled, + ) +} + +// BashToolRenderContext holds context for rendering bash tool messages. +// +// It implements the [ToolRenderer] interface. +type BashToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) const toolName = "Bash" if !opts.ToolCall.Finished && !opts.Canceled { diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 000847a192a106f45aa6836281e50bf89426f20f..6bcb3a1c1fef2353f77329dd8814e277367fa948 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -8,7 +8,6 @@ import ( "strings" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/list" @@ -182,17 +181,6 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m return []MessageItem{} } -// ToolRenderer returns the appropriate [ToolRenderFunc] for a given tool call. -// this should be used for nested tools as well. -func ToolRenderer(tc message.ToolCall) ToolRenderFunc { - switch tc.Name { - case tools.BashToolName: - return BashToolRenderer - default: - return DefaultToolRenderer - } -} - // shouldRenderAssistantMessage determines if an assistant message should be rendered // // In some cases the assistant message only has tools so we do not want to render an diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 1847ac72287af31b7a0026c2ba60f85366a19e0a..705462e2c10c049bccb7c2237e98b20a8f03477e 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -30,6 +30,23 @@ const ( ToolStatusCanceled ) +// ToolMessageItem represents a tool call message in the chat UI. +type ToolMessageItem interface { + MessageItem + + ToolCall() message.ToolCall + SetToolCall(tc message.ToolCall) + SetResult(res *message.ToolResult) +} + +// DefaultToolRenderContext implements the default [ToolRenderer] interface. +type DefaultToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name +} + // ToolRenderOpts contains the data needed to render a tool call. type ToolRenderOpts struct { ToolCall message.ToolCall @@ -60,21 +77,26 @@ func (opts *ToolRenderOpts) Status() ToolStatus { return ToolStatusRunning } -// ToolRenderFunc is a function that renders a tool call to a string. -type ToolRenderFunc func(sty *styles.Styles, width int, t *ToolRenderOpts) string +// ToolRenderer represents an interface for rendering tool calls. +type ToolRenderer interface { + RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string +} -// DefaultToolRenderer is a placeholder renderer for tools without a custom renderer. -func DefaultToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name +// ToolRendererFunc is a function type that implements the [ToolRenderer] interface. +type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string + +// RenderTool implements the ToolRenderer interface. +func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + return f(sty, width, opts) } -// ToolMessageItem represents a tool call message that can be displayed in the UI. -type ToolMessageItem struct { +// baseToolMessageItem represents a tool call message that can be displayed in the UI. +type baseToolMessageItem struct { *highlightableMessageItem *cachedMessageItem *focusableMessageItem - renderFunc ToolRenderFunc + toolRenderer ToolRenderer toolCall message.ToolCall result *message.ToolResult canceled bool @@ -89,21 +111,23 @@ type ToolMessageItem struct { expanded bool } -// NewToolMessageItem creates a new tool message item with the given renderFunc. -func NewToolMessageItem( +// newBaseToolMessageItem is the internal constructor for base tool message items. +func newBaseToolMessageItem( sty *styles.Styles, toolCall message.ToolCall, result *message.ToolResult, + toolRenderer ToolRenderer, canceled bool, -) *ToolMessageItem { +) *baseToolMessageItem { // we only do full width for diffs (as far as I know) hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName - t := &ToolMessageItem{ + + t := &baseToolMessageItem{ highlightableMessageItem: defaultHighlighter(sty), cachedMessageItem: &cachedMessageItem{}, focusableMessageItem: &focusableMessageItem{}, sty: sty, - renderFunc: ToolRenderer(toolCall), + toolRenderer: toolRenderer, toolCall: toolCall, result: result, canceled: canceled, @@ -117,16 +141,42 @@ func NewToolMessageItem( LabelColor: sty.FgBase, CycleColors: true, }) + return t } +// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name. +// +// It returns a specific tool message item type if implemented, otherwise it +// returns a generic tool message item. +func NewToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + switch toolCall.Name { + case tools.BashToolName: + return NewBashToolMessageItem(sty, toolCall, result, canceled) + default: + // TODO: Implement other tool items + return newBaseToolMessageItem( + sty, + toolCall, + result, + &DefaultToolRenderContext{}, + canceled, + ) + } +} + // ID returns the unique identifier for this tool message item. -func (t *ToolMessageItem) ID() string { +func (t *baseToolMessageItem) ID() string { return t.toolCall.ID } // StartAnimation starts the assistant message animation if it should be spinning. -func (t *ToolMessageItem) StartAnimation() tea.Cmd { +func (t *baseToolMessageItem) StartAnimation() tea.Cmd { if !t.isSpinning() { return nil } @@ -134,7 +184,7 @@ func (t *ToolMessageItem) StartAnimation() tea.Cmd { } // Animate progresses the assistant message animation if it should be spinning. -func (t *ToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { +func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { if !t.isSpinning() { return nil } @@ -142,7 +192,7 @@ func (t *ToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { } // Render renders the tool message item at the given width. -func (t *ToolMessageItem) Render(width int) string { +func (t *baseToolMessageItem) Render(width int) string { toolItemWidth := width - messageLeftPaddingTotal if t.hasCappedWidth { toolItemWidth = cappedMessageWidth(width) @@ -155,7 +205,7 @@ func (t *ToolMessageItem) Render(width int) string { content, height, ok := t.getCachedRender(toolItemWidth) // if we are spinning or there is no cache rerender if !ok || t.isSpinning() { - content = t.renderFunc(t.sty, toolItemWidth, &ToolRenderOpts{ + content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{ ToolCall: t.toolCall, Result: t.result, Canceled: t.canceled, @@ -175,47 +225,51 @@ func (t *ToolMessageItem) Render(width int) string { } // ToolCall returns the tool call associated with this message item. -func (t *ToolMessageItem) ToolCall() message.ToolCall { +func (t *baseToolMessageItem) ToolCall() message.ToolCall { return t.toolCall } // SetToolCall sets the tool call associated with this message item. -func (t *ToolMessageItem) SetToolCall(tc message.ToolCall) { +func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) { t.toolCall = tc t.clearCache() } // SetResult sets the tool result associated with this message item. -func (t *ToolMessageItem) SetResult(res *message.ToolResult) { +func (t *baseToolMessageItem) SetResult(res *message.ToolResult) { t.result = res t.clearCache() } // SetPermissionRequested sets whether permission has been requested for this tool call. -func (t *ToolMessageItem) SetPermissionRequested(requested bool) { +// TODO: Consider merging with SetPermissionGranted and add an interface for +// permission management. +func (t *baseToolMessageItem) SetPermissionRequested(requested bool) { t.permissionRequested = requested t.clearCache() } // SetPermissionGranted sets whether permission has been granted for this tool call. -func (t *ToolMessageItem) SetPermissionGranted(granted bool) { +// TODO: Consider merging with SetPermissionRequested and add an interface for +// permission management. +func (t *baseToolMessageItem) SetPermissionGranted(granted bool) { t.permissionGranted = granted t.clearCache() } // isSpinning returns true if the tool should show animation. -func (t *ToolMessageItem) isSpinning() bool { +func (t *baseToolMessageItem) isSpinning() bool { return !t.toolCall.Finished && !t.canceled } // ToggleExpanded toggles the expanded state of the thinking box. -func (t *ToolMessageItem) ToggleExpanded() { +func (t *baseToolMessageItem) ToggleExpanded() { t.expanded = !t.expanded t.clearCache() } // HandleMouseClick implements MouseClickable. -func (t *ToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool { +func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool { if btn != ansi.MouseLeft { return false } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 2dab87ba0de0eac8ed365938c7abc8395785c9b2..99f75f98fbf666dda9d3b05ef985977f071c4e46 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -426,7 +426,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { // we should have an item! continue } - if toolMsgItem, ok := toolItem.(*chat.ToolMessageItem); ok { + if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok { toolMsgItem.SetResult(&tr) } } @@ -451,7 +451,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { var items []chat.MessageItem for _, tc := range msg.ToolCalls() { existingToolItem := m.chat.MessageItem(tc.ID) - if toolItem, ok := existingToolItem.(*chat.ToolMessageItem); ok { + if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok { existingToolCall := toolItem.ToolCall() // only update if finished state changed or input changed // to avoid clearing the cache From e86304d5a6f6d9c0a806ae9e402ee971fcf8ee62 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 15:20:17 -0500 Subject: [PATCH 084/335] fix(ui): remove redundant check in thinking rendering --- internal/ui/chat/assistant.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index de1e6dceddfd4f2939190d6d4bde46ce3ff8fb55..026889f25ad22d76704b26485b5299080934009a 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -152,9 +152,6 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string isTruncated := totalLines > maxCollapsedThinkingHeight if !a.thinkingExpanded && isTruncated { lines = lines[totalLines-maxCollapsedThinkingHeight:] - } - - if !a.thinkingExpanded && isTruncated { hint := a.sty.Chat.Message.ThinkingTruncationHint.Render( fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight), ) From 8e244e651a6d8e662f88e6b640ef3541a319ae62 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 15:21:49 -0500 Subject: [PATCH 085/335] fix(ui): improve thinking message truncation display --- internal/ui/chat/assistant.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 026889f25ad22d76704b26485b5299080934009a..5efa9c8b6a72aa9644f34618ad50b89a2aae3913 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -155,7 +155,7 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string hint := a.sty.Chat.Message.ThinkingTruncationHint.Render( fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight), ) - lines = append([]string{hint}, lines...) + lines = append(lines, "", hint) } thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width) From 26145a76b5f82158dc0d3ab7a4a513aed3a8b7e1 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 17 Dec 2025 16:39:21 -0500 Subject: [PATCH 086/335] feat(ui): model selection dialog --- internal/ui/common/elements.go | 14 +- internal/ui/dialog/commands.go | 14 +- internal/ui/dialog/models.go | 445 ++++++++++++++++++++++++++++++ internal/ui/dialog/models_item.go | 95 +++++++ internal/ui/dialog/models_list.go | 163 +++++++++++ internal/ui/model/ui.go | 23 +- internal/ui/styles/styles.go | 24 +- 7 files changed, 760 insertions(+), 18 deletions(-) create mode 100644 internal/ui/dialog/models.go create mode 100644 internal/ui/dialog/models_item.go create mode 100644 internal/ui/dialog/models_list.go diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index 1475d123a514b75b930b9479ec59f5e5d8a19cf3..f2256676d47b5f5b706ca05e7e4d5a21d6d5867a 100644 --- a/internal/ui/common/elements.go +++ b/internal/ui/common/elements.go @@ -134,13 +134,23 @@ func Status(t *styles.Styles, opts StatusOpts, width int) string { // Section renders a section header with a title and a horizontal line filling // the remaining width. -func Section(t *styles.Styles, text string, width int) string { +func Section(t *styles.Styles, text string, width int, info ...string) string { char := styles.SectionSeparator length := lipgloss.Width(text) + 1 remainingWidth := width - length + + var infoText string + if len(info) > 0 { + infoText = strings.Join(info, " ") + if len(infoText) > 0 { + infoText = " " + infoText + remainingWidth -= lipgloss.Width(infoText) + } + } + text = t.Section.Title.Render(text) if remainingWidth > 0 { - text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth)) + text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth)) + infoText } return text } diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 1d45064a1a716ae1a6736a27a15c6c358b2e108e..a2c0948f26b75ab7b9d597ca5da867b76c936867 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -197,22 +197,23 @@ func (c *Commands) Cursor() *tea.Cursor { return InputCursor(c.com.Styles, c.input.Cursor()) } -// radioView generates the command type selector radio buttons. -func radioView(t *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string { +// commandsRadioView generates the command type selector radio buttons. +func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string { if !hasUserCmds && !hasMCPPrompts { return "" } selectedFn := func(t uicmd.CommandType) string { if t == selected { - return " ◉ " + t.String() + return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String()) } - return " ○ " + t.String() + return sty.RadioOff.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String()) } parts := []string{ selectedFn(uicmd.SystemCommands), } + if hasUserCmds { parts = append(parts, selectedFn(uicmd.UserCommands)) } @@ -220,14 +221,13 @@ func radioView(t *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, h parts = append(parts, selectedFn(uicmd.MCPPrompts)) } - radio := strings.Join(parts, " ") - return t.Dialog.Commands.CommandTypeSelector.Render(radio) + return strings.Join(parts, " ") } // View implements [Dialog]. func (c *Commands) View() string { t := c.com.Styles - radio := radioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0) + radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0) titleStyle := t.Dialog.Title dialogStyle := t.Dialog.View.Width(c.width) headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go new file mode 100644 index 0000000000000000000000000000000000000000..66f5923dceccf2633becef38a282de42e9b86785 --- /dev/null +++ b/internal/ui/dialog/models.go @@ -0,0 +1,445 @@ +package dialog + +import ( + "cmp" + "fmt" + "slices" + "strings" + + "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/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/uiutil" +) + +// ModelType represents the type of model to select. +type ModelType int + +const ( + ModelTypeLarge ModelType = iota + ModelTypeSmall +) + +// String returns the string representation of the [ModelType]. +func (mt ModelType) String() string { + switch mt { + case ModelTypeLarge: + return "Large Task" + case ModelTypeSmall: + return "Small Task" + default: + return "Unknown" + } +} + +const ( + largeModelInputPlaceholder = "Choose a model for large, complex tasks" + smallModelInputPlaceholder = "Choose a model for small, simple tasks" +) + +// ModelsID is the identifier for the model selection dialog. +const ModelsID = "models" + +// Models represents a model selection dialog. +type Models struct { + com *common.Common + + modelType ModelType + providers []catwalk.Provider + + width, height int + + keyMap struct { + Tab key.Binding + UpDown key.Binding + Select key.Binding + Next key.Binding + Previous key.Binding + Close key.Binding + } + list *ModelsList + input textinput.Model + help help.Model +} + +var _ Dialog = (*Models)(nil) + +// NewModels creates a new Models dialog. +func NewModels(com *common.Common) (*Models, error) { + t := com.Styles + m := &Models{} + m.com = com + help := help.New() + help.Styles = t.DialogHelpStyles() + + m.help = help + m.list = NewModelsList(t) + m.list.Focus() + m.list.SetSelected(0) + + m.input = textinput.New() + m.input.SetVirtualCursor(false) + m.input.Placeholder = largeModelInputPlaceholder + m.input.SetStyles(com.Styles.TextInput) + m.input.Focus() + + m.keyMap.Tab = key.NewBinding( + key.WithKeys("tab", "shift+tab"), + key.WithHelp("tab", "toggle type"), + ) + m.keyMap.Select = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ) + m.keyMap.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ) + m.keyMap.Next = key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ) + m.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ) + 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 + if err := m.setProviderItems(); err != nil { + return nil, fmt.Errorf("failed to set provider items: %w", err) + } + + return m, nil +} + +// SetSize sets the size of the dialog. +func (m *Models) SetSize(width, height int) { + t := m.com.Styles + m.width = width + m.height = height + innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content + t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content + t.Dialog.HelpView.GetVerticalFrameSize() + + t.Dialog.View.GetVerticalFrameSize() + m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + m.list.SetSize(innerWidth, height-heightOffset) + m.help.SetWidth(width) +} + +// ID implements Dialog. +func (m *Models) ID() string { + return ModelsID +} + +// Update implements Dialog. +func (m *Models) Update(msg tea.Msg) tea.Msg { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keyMap.Close): + return CloseMsg{} + case key.Matches(msg, m.keyMap.Previous): + m.list.Focus() + if m.list.IsSelectedFirst() { + m.list.SelectLast() + m.list.ScrollToBottom() + break + } + m.list.SelectPrev() + m.list.ScrollToSelected() + case key.Matches(msg, m.keyMap.Next): + m.list.Focus() + if m.list.IsSelectedLast() { + m.list.SelectFirst() + m.list.ScrollToTop() + break + } + m.list.SelectNext() + m.list.ScrollToSelected() + case key.Matches(msg, m.keyMap.Select): + if selectedItem := m.list.SelectedItem(); selectedItem != nil { + // TODO: Handle model selection confirmation. + } + case key.Matches(msg, m.keyMap.Tab): + if m.modelType == ModelTypeLarge { + m.modelType = ModelTypeSmall + } else { + m.modelType = ModelTypeLarge + } + if err := m.setProviderItems(); err != nil { + return uiutil.ReportError(err) + } + default: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + value := m.input.Value() + m.list.SetFilter(value) + m.list.ScrollToSelected() + return cmd + } + } + return nil +} + +// Cursor returns the cursor for the dialog. +func (m *Models) Cursor() *tea.Cursor { + return InputCursor(m.com.Styles, m.input.Cursor()) +} + +// modelTypeRadioView returns the radio view for model type selection. +func (m *Models) modelTypeRadioView() string { + t := m.com.Styles + textStyle := t.HalfMuted + largeRadioStyle := t.RadioOff + smallRadioStyle := t.RadioOff + if m.modelType == ModelTypeLarge { + largeRadioStyle = t.RadioOn + } else { + smallRadioStyle = t.RadioOn + } + + largeRadio := largeRadioStyle.Padding(0, 1).Render() + smallRadio := smallRadioStyle.Padding(0, 1).Render() + + return fmt.Sprintf("%s%s %s%s", + largeRadio, textStyle.Render(ModelTypeLarge.String()), + smallRadio, textStyle.Render(ModelTypeSmall.String())) +} + +// View implements Dialog. +func (m *Models) View() string { + t := m.com.Styles + titleStyle := t.Dialog.Title + dialogStyle := t.Dialog.View + + radios := m.modelTypeRadioView() + + headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() + + dialogStyle.GetHorizontalFrameSize() + + header := common.DialogTitle(t, "Switch Model", m.width-headerOffset) + radios + + return HeaderInputListHelpView(t, m.width, m.list.Height(), header, + m.input.View(), m.list.Render(), m.help.View(m)) +} + +// ShortHelp returns the short help view. +func (m *Models) ShortHelp() []key.Binding { + return []key.Binding{ + m.keyMap.UpDown, + m.keyMap.Tab, + m.keyMap.Select, + m.keyMap.Close, + } +} + +// 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, + }, + } +} + +// setProviderItems sets the provider items in the list. +func (m *Models) setProviderItems() error { + t := m.com.Styles + cfg := m.com.Config() + + selectedType := config.SelectedModelTypeLarge + if m.modelType == ModelTypeLarge { + selectedType = config.SelectedModelTypeLarge + } else { + selectedType = config.SelectedModelTypeSmall + } + + var selectedItemID string + currentModel := cfg.Models[selectedType] + recentItems := cfg.RecentModels[selectedType] + + // Track providers already added to avoid duplicates + addedProviders := make(map[string]bool) + + // Get a list of known providers to compare against + knownProviders, err := config.Providers(cfg) + if err != nil { + return fmt.Errorf("failed to get providers: %w", err) + } + + containsProviderFunc := func(id string) func(p catwalk.Provider) bool { + return func(p catwalk.Provider) bool { + return p.ID == catwalk.InferenceProvider(id) + } + } + + // itemsMap contains the keys of added model items. + itemsMap := make(map[string]*ModelItem) + groups := []ModelGroup{} + for id, p := range cfg.Providers.Seq2() { + if p.Disable { + continue + } + + // Check if this provider is not in the known providers list + if !slices.ContainsFunc(knownProviders, containsProviderFunc(id)) || + !slices.ContainsFunc(m.providers, containsProviderFunc(id)) { + provider := p.ToProvider() + + // Add this unknown provider to the list + name := p.Name + if name == "" { + name = id + } + + addedProviders[id] = true + + group := NewModelGroup(t, name, true) + for _, model := range p.Models { + item := NewModelItem(t, provider, model) + group.AppendItems(item) + itemsMap[item.ID()] = item + if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { + selectedItemID = item.ID() + } + } + } + } + + // Now add known providers from the predefined list + for _, provider := range m.providers { + providerID := string(provider.ID) + if addedProviders[providerID] { + continue + } + + providerConfig, providerConfigured := cfg.Providers.Get(providerID) + 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 = providerID + } + + group := NewModelGroup(t, name, providerConfigured) + for _, model := range displayProvider.Models { + item := NewModelItem(t, provider, model) + group.AppendItems(item) + itemsMap[item.ID()] = item + if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { + selectedItemID = item.ID() + } + } + + groups = append(groups, group) + } + + if len(recentItems) > 0 { + recentGroup := NewModelGroup(t, "Recently used", false) + + var validRecentItems []config.SelectedModel + for _, recent := range recentItems { + key := modelKey(recent.Provider, recent.Model) + item, ok := itemsMap[key] + if !ok { + continue + } + + validRecentItems = append(validRecentItems, recent) + recentGroup.AppendItems(item) + if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider { + selectedItemID = item.ID() + } + } + + if len(validRecentItems) != len(recentItems) { + // FIXME: Does this need to be here? Is it mutating the config during a read? + if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil { + return fmt.Errorf("failed to update recent models: %w", err) + } + } + + if len(recentGroup.Items) > 0 { + groups = append([]ModelGroup{recentGroup}, groups...) + } + } + + // Set model groups in the list. + m.list.SetGroups(groups...) + m.list.SetSelectedItem(selectedItemID) + // Update placeholder based on model type + if m.modelType == ModelTypeLarge { + m.input.Placeholder = largeModelInputPlaceholder + } else { + m.input.Placeholder = smallModelInputPlaceholder + } + + 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) + } + filteredProviders := []catwalk.Provider{} + for _, p := range providers { + hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$") + if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure { + filteredProviders = append(filteredProviders, p) + } + } + return filteredProviders, nil +} + +func modelKey(providerID, modelID string) string { + if providerID == "" || modelID == "" { + return "" + } + return providerID + ":" + modelID +} diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go new file mode 100644 index 0000000000000000000000000000000000000000..7dfe98d986a38cfefeee12151deb40722227287b --- /dev/null +++ b/internal/ui/dialog/models_item.go @@ -0,0 +1,95 @@ +package dialog + +import ( + "charm.land/lipgloss/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" + "github.com/sahilm/fuzzy" +) + +// ModelGroup represents a group of model items. +type ModelGroup struct { + Title string + Items []*ModelItem + configured bool + t *styles.Styles +} + +// NewModelGroup creates a new ModelGroup. +func NewModelGroup(t *styles.Styles, title string, configured bool, items ...*ModelItem) ModelGroup { + return ModelGroup{ + Title: title, + Items: items, + t: t, + } +} + +// AppendItems appends [ModelItem]s to the group. +func (m *ModelGroup) AppendItems(items ...*ModelItem) { + m.Items = append(m.Items, items...) +} + +// Render implements [list.Item]. +func (m *ModelGroup) Render(width int) string { + var configured string + if m.configured { + configuredIcon := m.t.ToolCallSuccess.Render() + configuredText := m.t.Subtle.Render("Configured") + configured = configuredIcon + " " + configuredText + } + + title := " " + m.Title + " " + title = ansi.Truncate(title, max(0, width-lipgloss.Width(configured)-1), "…") + + return common.Section(m.t, title, width, configured) +} + +// ModelItem represents a list item for a model type. +type ModelItem struct { + prov catwalk.Provider + model catwalk.Model + + cache map[int]string + t *styles.Styles + m fuzzy.Match + focused bool +} + +var _ ListItem = &ModelItem{} + +// NewModelItem creates a new ModelItem. +func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model) *ModelItem { + return &ModelItem{ + prov: prov, + model: model, + t: t, + cache: make(map[int]string), + } +} + +// Filter implements ListItem. +func (m *ModelItem) Filter() string { + return m.model.Name +} + +// ID implements ListItem. +func (m *ModelItem) ID() string { + return modelKey(string(m.prov.ID), m.model.ID) +} + +// Render implements ListItem. +func (m *ModelItem) Render(width int) string { + return renderItem(m.t, m.model.Name, 0, m.focused, width, m.cache, &m.m) +} + +// SetFocused implements ListItem. +func (m *ModelItem) SetFocused(focused bool) { + m.focused = focused +} + +// SetMatch implements ListItem. +func (m *ModelItem) SetMatch(fm fuzzy.Match) { + m.m = fm +} diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go new file mode 100644 index 0000000000000000000000000000000000000000..13a1137bd2e1d076dbb95fe97d3c8850e9cca922 --- /dev/null +++ b/internal/ui/dialog/models_list.go @@ -0,0 +1,163 @@ +package dialog + +import ( + "slices" + + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/sahilm/fuzzy" +) + +// ModelsList is a list specifically for model items and groups. +type ModelsList struct { + *list.List + groups []ModelGroup + items []list.Item + query string + t *styles.Styles +} + +// NewModelsList creates a new list suitable for model items and groups. +func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList { + f := &ModelsList{ + List: list.NewList(), + groups: groups, + t: sty, + } + return f +} + +// SetGroups sets the model groups and updates the list items. +func (f *ModelsList) SetGroups(groups ...ModelGroup) { + f.groups = groups +} + +// SetFilter sets the filter query and updates the list items. +func (f *ModelsList) SetFilter(q string) { + f.query = q +} + +// SetSelectedItem sets the selected item in the list by item ID. +func (f *ModelsList) SetSelectedItem(itemID string) { + count := 0 + for _, g := range f.groups { + for _, item := range g.Items { + if item.ID() == itemID { + f.List.SetSelected(count) + return + } + count++ + } + } +} + +// SelectNext selects the next selectable item in the list. +func (f *ModelsList) SelectNext() bool { + for f.List.SelectNext() { + if _, ok := f.List.SelectedItem().(*ModelItem); ok { + return true + } + } + return false +} + +// SelectPrev selects the previous selectable item in the list. +func (f *ModelsList) SelectPrev() bool { + for f.List.SelectPrev() { + if _, ok := f.List.SelectedItem().(*ModelItem); ok { + return true + } + } + return false +} + +// VisibleItems returns the visible items after filtering. +func (f *ModelsList) VisibleItems() []list.Item { + if len(f.query) == 0 { + // No filter, return all items with group headers + items := []list.Item{} + for _, g := range f.groups { + items = append(items, &g) + for _, item := range g.Items { + items = append(items, item) + } + // Add a space separator after each provider section + items = append(items, list.NewSpacerItem(1)) + } + return items + } + + groupItems := map[int][]*ModelItem{} + filterableItems := []list.FilterableItem{} + for i, g := range f.groups { + for _, item := range g.Items { + filterableItems = append(filterableItems, item) + groupItems[i] = append(groupItems[i], item) + } + } + + matches := fuzzy.FindFrom(f.query, list.FilterableItemsSource(filterableItems)) + for _, match := range matches { + item := filterableItems[match.Index] + if ms, ok := item.(list.MatchSettable); ok { + ms.SetMatch(match) + item = ms.(list.FilterableItem) + } + filterableItems = append(filterableItems, item) + } + + items := []list.Item{} + visitedGroups := map[int]bool{} + + // Reconstruct groups with matched items + for _, match := range matches { + item := filterableItems[match.Index] + // Find which group this item belongs to + for gi, g := range f.groups { + if slices.Contains(groupItems[gi], item.(*ModelItem)) { + if !visitedGroups[gi] { + // Add section header + items = append(items, &g) + visitedGroups[gi] = true + } + // Add the matched item + if ms, ok := item.(list.MatchSettable); ok { + ms.SetMatch(match) + item = ms.(list.FilterableItem) + } + // Add a space separator after each provider section + items = append(items, item, list.NewSpacerItem(1)) + break + } + } + } + + return items +} + +// Render renders the filterable list. +func (f *ModelsList) Render() string { + f.List.SetItems(f.VisibleItems()...) + return f.List.Render() +} + +type modelGroups []ModelGroup + +func (m modelGroups) Len() int { + n := 0 + for _, g := range m { + n += len(g.Items) + } + return n +} + +func (m modelGroups) String(i int) string { + count := 0 + for _, g := range m { + if i < count+len(g.Items) { + return g.Items[i-count].Filter() + } + count += len(g.Items) + } + return "" +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 99f75f98fbf666dda9d3b05ef985977f071c4e46..817c2ad5340f760f246e47aafe1f226998bf1659 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -494,7 +494,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } return true case key.Matches(msg, m.keyMap.Models): - // TODO: Implement me + if cmd := m.openModelsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } return true case key.Matches(msg, m.keyMap.Sessions): if m.dialog.ContainsDialog(dialog.SessionsID) { @@ -1375,6 +1377,25 @@ func (m *UI) openQuitDialog() tea.Cmd { return nil } +// openModelsDialog opens the models dialog. +func (m *UI) openModelsDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.ModelsID) { + // Bring to front + m.dialog.BringToFront(dialog.ModelsID) + return nil + } + + modelsDialog, err := dialog.NewModels(m.com) + if err != nil { + return uiutil.ReportError(err) + } + + modelsDialog.SetSize(min(60, m.width-8), 30) + m.dialog.OpenDialog(modelsDialog) + + return nil +} + // openCommandsDialog opens the commands dialog. func (m *UI) openCommandsDialog() tea.Cmd { if m.dialog.ContainsDialog(dialog.CommandsID) { diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 21ace6a1e588f97a4be586cc01c5ecf3f0a88984..a91e3810142e259dfd38ad7827e9577699a112ae 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -32,6 +32,9 @@ const ( ToolSuccess string = "✓" ToolError string = "×" + RadioOn string = "◉" + RadioOff string = "○" + BorderThin string = "│" BorderThick string = "▌" @@ -51,9 +54,10 @@ type Styles struct { WindowTooSmall lipgloss.Style // Reusable text styles - Base lipgloss.Style - Muted lipgloss.Style - Subtle lipgloss.Style + Base lipgloss.Style + Muted lipgloss.Style + HalfMuted lipgloss.Style + Subtle lipgloss.Style // Tags TagBase lipgloss.Style @@ -123,6 +127,10 @@ type Styles struct { EditorPromptYoloDotsFocused lipgloss.Style EditorPromptYoloDotsBlurred lipgloss.Style + // Radio + RadioOn lipgloss.Style + RadioOff lipgloss.Style + // Background Background color.Color @@ -291,9 +299,7 @@ type Styles struct { List lipgloss.Style - Commands struct { - CommandTypeSelector lipgloss.Style - } + Commands struct{} } } @@ -917,6 +923,7 @@ func DefaultStyles() Styles { // text presets s.Base = lipgloss.NewStyle().Foreground(fgBase) s.Muted = lipgloss.NewStyle().Foreground(fgMuted) + s.HalfMuted = lipgloss.NewStyle().Foreground(fgHalfMuted) s.Subtle = lipgloss.NewStyle().Foreground(fgSubtle) s.WindowTooSmall = s.Muted @@ -1008,6 +1015,9 @@ func DefaultStyles() Styles { s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Zest).SetString(":::") s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid) + s.RadioOn = s.HalfMuted.SetString(RadioOn) + s.RadioOff = s.HalfMuted.SetString(RadioOff) + // Logo colors s.LogoFieldColor = primary s.LogoTitleColorA = secondary @@ -1095,8 +1105,6 @@ func DefaultStyles() Styles { s.Dialog.List = base.Margin(0, 0, 1, 0) - s.Dialog.Commands.CommandTypeSelector = base.Foreground(fgHalfMuted) - return s } From b4495455256ca33210e67c76722ec93c985dd694 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 15:50:19 -0500 Subject: [PATCH 087/335] fix(ui): properly align session item age text --- internal/ui/dialog/sessions_item.go | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index 9860581eb3c967154b09b42217e2283192962373..23434bf53e0cd53bb6dc863ac7903c1acf6766f4 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -1,6 +1,7 @@ package dialog import ( + "fmt" "strings" "time" @@ -71,35 +72,23 @@ func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, w style = t.Dialog.SelectedItem } - width -= style.GetHorizontalFrameSize() - var age string + var ageLen int + lineWidth := width if updatedAt > 0 { - age = humanize.Time(time.Unix(updatedAt, 0)) + age = fmt.Sprintf(" %s ", humanize.Time(time.Unix(updatedAt, 0))) if focused { age = t.Base.Render(age) } else { age = t.Subtle.Render(age) } - age = " " + age - } - - var ageLen int - var right string - lineWidth := width - if updatedAt > 0 { ageLen = lipgloss.Width(age) - lineWidth -= ageLen } - title = ansi.Truncate(title, max(0, lineWidth), "…") + title = ansi.Truncate(title, max(0, lineWidth), "") titleLen := lipgloss.Width(title) - - if updatedAt > 0 { - right = lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age) - } - + gap := strings.Repeat(" ", max(0, lineWidth-titleLen-ageLen)) content := title if matches := len(m.MatchedIndexes); matches > 0 { var lastPos int @@ -129,7 +118,7 @@ func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, w content = strings.Join(parts, "") } - content = style.Render(content + right) + content = style.Render(content + gap + age) cache[width] = content return content } From 179e17f64604c2facf843199b069d23c08a5155c Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 15:57:02 -0500 Subject: [PATCH 088/335] fix(ui): dialog: show shortcut/info in list items --- internal/ui/dialog/commands_item.go | 2 +- internal/ui/dialog/models_item.go | 2 +- internal/ui/dialog/sessions_item.go | 23 ++++++++++++----------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go index 79f0aa047ee22a691d117014c33bc7e551b1a29b..408fe70865bfb02ce446c57c32c2b3d79bfd8fe5 100644 --- a/internal/ui/dialog/commands_item.go +++ b/internal/ui/dialog/commands_item.go @@ -51,5 +51,5 @@ func (c *CommandItem) SetMatch(m fuzzy.Match) { // Render implements ListItem. func (c *CommandItem) Render(width int) string { - return renderItem(c.t, c.Cmd.Title, 0, c.focused, width, c.cache, &c.m) + return renderItem(c.t, c.Cmd.Title, c.Cmd.Shortcut, c.focused, width, c.cache, &c.m) } diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go index 7dfe98d986a38cfefeee12151deb40722227287b..1493bce8458f117068a8a68e7d2439b2b4b56432 100644 --- a/internal/ui/dialog/models_item.go +++ b/internal/ui/dialog/models_item.go @@ -81,7 +81,7 @@ func (m *ModelItem) ID() string { // Render implements ListItem. func (m *ModelItem) Render(width int) string { - return renderItem(m.t, m.model.Name, 0, m.focused, width, m.cache, &m.m) + return renderItem(m.t, m.model.Name, "", m.focused, width, m.cache, &m.m) } // SetFocused implements ListItem. diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index 23434bf53e0cd53bb6dc863ac7903c1acf6766f4..ddff75806e8345ca1140622739933c31055658e6 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -54,10 +54,11 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) { // Render returns the string representation of the session item. func (s *SessionItem) Render(width int) string { - return renderItem(s.t, s.Title, s.UpdatedAt, s.focused, width, s.cache, &s.m) + info := humanize.Time(time.Unix(s.UpdatedAt, 0)) + return renderItem(s.t, s.Title, info, s.focused, width, s.cache, &s.m) } -func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, width int, cache map[int]string, m *fuzzy.Match) string { +func renderItem(t *styles.Styles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string { if cache == nil { cache = make(map[int]string) } @@ -72,23 +73,23 @@ func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, w style = t.Dialog.SelectedItem } - var age string - var ageLen int + var infoText string + var infoLen int lineWidth := width - if updatedAt > 0 { - age = fmt.Sprintf(" %s ", humanize.Time(time.Unix(updatedAt, 0))) + if len(info) > 0 { + infoText = fmt.Sprintf(" %s ", info) if focused { - age = t.Base.Render(age) + infoText = t.Base.Render(infoText) } else { - age = t.Subtle.Render(age) + infoText = t.Subtle.Render(infoText) } - ageLen = lipgloss.Width(age) + infoLen = lipgloss.Width(infoText) } title = ansi.Truncate(title, max(0, lineWidth), "") titleLen := lipgloss.Width(title) - gap := strings.Repeat(" ", max(0, lineWidth-titleLen-ageLen)) + gap := strings.Repeat(" ", max(0, lineWidth-titleLen-infoLen)) content := title if matches := len(m.MatchedIndexes); matches > 0 { var lastPos int @@ -118,7 +119,7 @@ func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, w content = strings.Join(parts, "") } - content = style.Render(content + gap + age) + content = style.Render(content + gap + infoText) cache[width] = content return content } From 82eafde6a9ce0b10ea44c3b54ee0ba6b01b9bf4f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 16:33:49 -0500 Subject: [PATCH 089/335] fix(ui): dialog: show provider name for recent models --- internal/ui/dialog/models.go | 9 ++++-- internal/ui/dialog/models_item.go | 37 +++++++++++++-------- internal/ui/dialog/models_list.go | 54 +++++++++++++++---------------- 3 files changed, 57 insertions(+), 43 deletions(-) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 66f5923dceccf2633becef38a282de42e9b86785..b87edba2cac4cd3927d3845a06dad3dca7c27bcd 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -314,7 +314,7 @@ func (m *Models) setProviderItems() error { group := NewModelGroup(t, name, true) for _, model := range p.Models { - item := NewModelItem(t, provider, model) + item := NewModelItem(t, provider, model, false) group.AppendItems(item) itemsMap[item.ID()] = item if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { @@ -368,7 +368,7 @@ func (m *Models) setProviderItems() error { group := NewModelGroup(t, name, providerConfigured) for _, model := range displayProvider.Models { - item := NewModelItem(t, provider, model) + item := NewModelItem(t, provider, model, false) group.AppendItems(item) itemsMap[item.ID()] = item if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { @@ -390,6 +390,10 @@ func (m *Models) setProviderItems() error { continue } + // Show provider for recent items + item = NewModelItem(t, item.prov, item.model, true) + item.showProvider = true + validRecentItems = append(validRecentItems, recent) recentGroup.AppendItems(item) if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider { @@ -412,6 +416,7 @@ func (m *Models) setProviderItems() error { // Set model groups in the list. m.list.SetGroups(groups...) m.list.SetSelectedItem(selectedItemID) + // Update placeholder based on model type if m.modelType == ModelTypeLarge { m.input.Placeholder = largeModelInputPlaceholder diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go index 1493bce8458f117068a8a68e7d2439b2b4b56432..46722f8592e417af5b90d6187d42a5cc11a89f7c 100644 --- a/internal/ui/dialog/models_item.go +++ b/internal/ui/dialog/models_item.go @@ -20,9 +20,10 @@ type ModelGroup struct { // NewModelGroup creates a new ModelGroup. func NewModelGroup(t *styles.Styles, title string, configured bool, items ...*ModelItem) ModelGroup { return ModelGroup{ - Title: title, - Items: items, - t: t, + Title: title, + Items: items, + configured: configured, + t: t, } } @@ -51,21 +52,23 @@ type ModelItem struct { prov catwalk.Provider model catwalk.Model - cache map[int]string - t *styles.Styles - m fuzzy.Match - focused bool + cache map[int]string + t *styles.Styles + m fuzzy.Match + focused bool + showProvider bool } var _ ListItem = &ModelItem{} // NewModelItem creates a new ModelItem. -func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model) *ModelItem { +func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model, showProvider bool) *ModelItem { return &ModelItem{ - prov: prov, - model: model, - t: t, - cache: make(map[int]string), + prov: prov, + model: model, + t: t, + cache: make(map[int]string), + showProvider: showProvider, } } @@ -81,15 +84,23 @@ func (m *ModelItem) ID() string { // Render implements ListItem. func (m *ModelItem) Render(width int) string { - return renderItem(m.t, m.model.Name, "", m.focused, width, m.cache, &m.m) + var providerInfo string + if m.showProvider { + providerInfo = string(m.prov.Name) + } + return renderItem(m.t, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m) } // SetFocused implements ListItem. func (m *ModelItem) SetFocused(focused bool) { + if m.focused != focused { + m.cache = nil + } m.focused = focused } // SetMatch implements ListItem. func (m *ModelItem) SetMatch(fm fuzzy.Match) { + m.cache = nil m.m = fm } diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index 13a1137bd2e1d076dbb95fe97d3c8850e9cca922..c42500caf276bd12a054b8024e31feff5a54740e 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -12,7 +12,6 @@ import ( type ModelsList struct { *list.List groups []ModelGroup - items []list.Item query string t *styles.Styles } @@ -30,6 +29,16 @@ func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList { // SetGroups sets the model groups and updates the list items. func (f *ModelsList) SetGroups(groups ...ModelGroup) { f.groups = groups + items := []list.Item{} + for _, g := range f.groups { + items = append(items, &g) + for _, item := range g.Items { + items = append(items, item) + } + // Add a space separator after each provider section + items = append(items, list.NewSpacerItem(1)) + } + f.List.SetItems(items...) } // SetFilter sets the filter query and updates the list items. @@ -39,6 +48,11 @@ func (f *ModelsList) SetFilter(q string) { // SetSelectedItem sets the selected item in the list by item ID. func (f *ModelsList) SetSelectedItem(itemID string) { + if itemID == "" { + f.SetSelected(0) + return + } + count := 0 for _, g := range f.groups { for _, item := range g.Items { @@ -51,26 +65,6 @@ func (f *ModelsList) SetSelectedItem(itemID string) { } } -// SelectNext selects the next selectable item in the list. -func (f *ModelsList) SelectNext() bool { - for f.List.SelectNext() { - if _, ok := f.List.SelectedItem().(*ModelItem); ok { - return true - } - } - return false -} - -// SelectPrev selects the previous selectable item in the list. -func (f *ModelsList) SelectPrev() bool { - for f.List.SelectPrev() { - if _, ok := f.List.SelectedItem().(*ModelItem); ok { - return true - } - } - return false -} - // VisibleItems returns the visible items after filtering. func (f *ModelsList) VisibleItems() []list.Item { if len(f.query) == 0 { @@ -110,10 +104,11 @@ func (f *ModelsList) VisibleItems() []list.Item { visitedGroups := map[int]bool{} // Reconstruct groups with matched items - for _, match := range matches { - item := filterableItems[match.Index] - // Find which group this item belongs to - for gi, g := range f.groups { + // Find which group this item belongs to + for gi, g := range f.groups { + addedCount := 0 + for _, match := range matches { + item := filterableItems[match.Index] if slices.Contains(groupItems[gi], item.(*ModelItem)) { if !visitedGroups[gi] { // Add section header @@ -125,11 +120,14 @@ func (f *ModelsList) VisibleItems() []list.Item { ms.SetMatch(match) item = ms.(list.FilterableItem) } - // Add a space separator after each provider section - items = append(items, item, list.NewSpacerItem(1)) - break + items = append(items, item) + addedCount++ } } + if addedCount > 0 { + // Add a space separator after each provider section + items = append(items, list.NewSpacerItem(1)) + } } return items From 67ac46a526623357497b409f2848a15b5dbee0be Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 16:35:04 -0500 Subject: [PATCH 090/335] fix(ui): dialog: no need for groupItems map in ModelsList.VisibleItems --- internal/ui/dialog/models_list.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index c42500caf276bd12a054b8024e31feff5a54740e..fedc33a9a6ceddbdc77ceb051952924465eb54c1 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -81,12 +81,10 @@ func (f *ModelsList) VisibleItems() []list.Item { return items } - groupItems := map[int][]*ModelItem{} filterableItems := []list.FilterableItem{} - for i, g := range f.groups { + for _, g := range f.groups { for _, item := range g.Items { filterableItems = append(filterableItems, item) - groupItems[i] = append(groupItems[i], item) } } @@ -109,7 +107,7 @@ func (f *ModelsList) VisibleItems() []list.Item { addedCount := 0 for _, match := range matches { item := filterableItems[match.Index] - if slices.Contains(groupItems[gi], item.(*ModelItem)) { + if slices.Contains(g.Items, item.(*ModelItem)) { if !visitedGroups[gi] { // Add section header items = append(items, &g) From e16eae1a23ea4849429d0f69f711dd58d3e12638 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 16:37:06 -0500 Subject: [PATCH 091/335] perf(ui): dialog: preallocate slice for filterable items in ModelsList --- internal/ui/dialog/models_list.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index fedc33a9a6ceddbdc77ceb051952924465eb54c1..c5707f65412c7fd2d5932377d07ab5b4d42467a3 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -26,6 +26,15 @@ func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList { return f } +// Len returns the number of model items across all groups. +func (f *ModelsList) Len() int { + n := 0 + for _, g := range f.groups { + n += len(g.Items) + } + return n +} + // SetGroups sets the model groups and updates the list items. func (f *ModelsList) SetGroups(groups ...ModelGroup) { f.groups = groups @@ -81,7 +90,7 @@ func (f *ModelsList) VisibleItems() []list.Item { return items } - filterableItems := []list.FilterableItem{} + filterableItems := make([]list.FilterableItem, 0, f.Len()) for _, g := range f.groups { for _, item := range g.Items { filterableItems = append(filterableItems, item) From 98df293310d9d86cb416647f2f526c1907e4d377 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 16:48:07 -0500 Subject: [PATCH 092/335] fix(ui): handle model selection in models dialog --- internal/ui/dialog/messages.go | 7 +++++++ internal/ui/dialog/models.go | 15 +++++++++++++-- internal/ui/model/ui.go | 7 +++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/internal/ui/dialog/messages.go b/internal/ui/dialog/messages.go index b3981ce382ea8712e93bd075600b3455748cb579..ca44d1118a57bd1506d5f35bb47d5f56bbe9fa6f 100644 --- a/internal/ui/dialog/messages.go +++ b/internal/ui/dialog/messages.go @@ -2,6 +2,7 @@ package dialog import ( tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/session" ) @@ -16,6 +17,12 @@ type SessionSelectedMsg struct { Session session.Session } +// ModelSelectedMsg is a message indicating a model has been selected. +type ModelSelectedMsg struct { + Provider catwalk.Provider + Model catwalk.Model +} + // Messages for commands type ( SwitchSessionsMsg struct{} diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index b87edba2cac4cd3927d3845a06dad3dca7c27bcd..47b50cd758d68e84c1b0615b0b98b237ebbbcdb6 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -169,8 +169,19 @@ func (m *Models) Update(msg tea.Msg) tea.Msg { m.list.SelectNext() m.list.ScrollToSelected() case key.Matches(msg, m.keyMap.Select): - if selectedItem := m.list.SelectedItem(); selectedItem != nil { - // TODO: Handle model selection confirmation. + selectedItem := m.list.SelectedItem() + if selectedItem == nil { + break + } + + modelItem, ok := selectedItem.(*ModelItem) + if !ok { + break + } + + return ModelSelectedMsg{ + Provider: modelItem.prov, + Model: modelItem.model, } case key.Matches(msg, m.keyMap.Tab): if m.modelType == ModelTypeLarge { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 817c2ad5340f760f246e47aafe1f226998bf1659..a8f80fe1f03f947ee11d05853f62e69927897afa 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -562,6 +562,13 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.dialog.CloseDialog(dialog.CommandsID) case dialog.QuitMsg: cmds = append(cmds, tea.Quit) + case dialog.SwitchModelMsg: + m.dialog.CloseDialog(dialog.CommandsID) + if cmd := m.openModelsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case dialog.ModelSelectedMsg: + // TODO: Handle model switching } return tea.Batch(cmds...) From eed0b49f8ceee2d23afd1dd3db0ff6ac295cb571 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 16:55:17 -0500 Subject: [PATCH 093/335] refactor(ui): dialog: unify session and model switching dialogs --- internal/ui/dialog/commands.go | 4 +-- internal/ui/dialog/messages.go | 7 ++-- internal/ui/model/ui.go | 60 ++++++++++++++++------------------ 3 files changed, 36 insertions(+), 35 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index a2c0948f26b75ab7b9d597ca5da867b76c936867..f97ea7374209b26e3702722079660af0619ba229 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -324,7 +324,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Description: "Switch to a different session", Shortcut: "ctrl+s", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(SwitchSessionsMsg{}) + return uiutil.CmdHandler(OpenDialogMsg{SessionsID}) }, }, { @@ -333,7 +333,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Description: "Switch to a different model", Shortcut: "ctrl+l", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(SwitchModelMsg{}) + return uiutil.CmdHandler(OpenDialogMsg{ModelsID}) }, }, } diff --git a/internal/ui/dialog/messages.go b/internal/ui/dialog/messages.go index ca44d1118a57bd1506d5f35bb47d5f56bbe9fa6f..2d69e4c9b841fcc9d8776e8e6fb5cf04e3d1d0f0 100644 --- a/internal/ui/dialog/messages.go +++ b/internal/ui/dialog/messages.go @@ -12,6 +12,11 @@ type CloseMsg struct{} // QuitMsg is a message to quit the application. type QuitMsg = tea.QuitMsg +// OpenDialogMsg is a message to open a dialog. +type OpenDialogMsg struct { + DialogID string +} + // SessionSelectedMsg is a message indicating a session has been selected. type SessionSelectedMsg struct { Session session.Session @@ -25,9 +30,7 @@ type ModelSelectedMsg struct { // Messages for commands type ( - SwitchSessionsMsg struct{} NewSessionsMsg struct{} - SwitchModelMsg struct{} OpenFilePickerMsg struct{} ToggleHelpMsg struct{} ToggleCompactModeMsg struct{} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a8f80fe1f03f947ee11d05853f62e69927897afa..ea656d5c8cfe42a97c58fa163b1689b11ac228af 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -64,11 +64,6 @@ type openEditorMsg struct { Text string } -// listSessionsMsg is a message to list available sessions. -type listSessionsMsg struct { - sessions []session.Session -} - // UI represents the main user interface model. type UI struct { com *common.Common @@ -190,10 +185,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.sendProgressBar { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } - case listSessionsMsg: - if cmd := m.openSessionsDialog(msg.sessions); cmd != nil { - cmds = append(cmds, cmd) - } case loadSessionMsg: m.state = uiChat m.session = msg.session @@ -499,11 +490,8 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } return true case key.Matches(msg, m.keyMap.Sessions): - if m.dialog.ContainsDialog(dialog.SessionsID) { - // Bring to front - m.dialog.BringToFront(dialog.SessionsID) - } else { - cmds = append(cmds, m.listSessions) + if cmd := m.openSessionsDialog(); cmd != nil { + cmds = append(cmds, cmd) } return true } @@ -536,15 +524,30 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.dialog.CloseDialog(dialog.SessionsID) cmds = append(cmds, m.loadSession(msg.Session.ID)) + // Open dialog message + case dialog.OpenDialogMsg: + switch msg.DialogID { + case dialog.SessionsID: + if cmd := m.openSessionsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case dialog.ModelsID: + if cmd := m.openModelsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + default: + // Unknown dialog + break + } + + m.dialog.CloseDialog(msg.DialogID) + // Command dialog messages case dialog.ToggleYoloModeMsg: yolo := !m.com.App.Permissions.SkipRequests() m.com.App.Permissions.SetSkipRequests(yolo) m.setEditorPrompt(yolo) m.dialog.CloseDialog(dialog.CommandsID) - case dialog.SwitchSessionsMsg: - cmds = append(cmds, m.listSessions) - m.dialog.CloseDialog(dialog.CommandsID) case dialog.NewSessionsMsg: if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) @@ -562,11 +565,6 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.dialog.CloseDialog(dialog.CommandsID) case dialog.QuitMsg: cmds = append(cmds, tea.Quit) - case dialog.SwitchModelMsg: - m.dialog.CloseDialog(dialog.CommandsID) - if cmd := m.openModelsDialog(); cmd != nil { - cmds = append(cmds, cmd) - } case dialog.ModelSelectedMsg: // TODO: Handle model switching } @@ -1428,14 +1426,21 @@ func (m *UI) openCommandsDialog() tea.Cmd { return nil } -// openSessionsDialog opens the sessions dialog with the given sessions. -func (m *UI) openSessionsDialog(sessions []session.Session) tea.Cmd { +// openSessionsDialog opens the sessions dialog. If the dialog is already open, +// it brings it to the front. Otherwise, it will list all the sessions and open +// the dialog. +func (m *UI) openSessionsDialog() tea.Cmd { if m.dialog.ContainsDialog(dialog.SessionsID) { // Bring to front m.dialog.BringToFront(dialog.SessionsID) return nil } + sessions, err := m.com.App.Sessions.List(context.TODO()) + if err != nil { + return uiutil.ReportError(err) + } + dialog := dialog.NewSessions(m.com, sessions...) // TODO: Get. Rid. Of. Magic numbers! dialog.SetSize(min(120, m.width-8), 30) @@ -1444,13 +1449,6 @@ func (m *UI) openSessionsDialog(sessions []session.Session) tea.Cmd { return nil } -// listSessions is a [tea.Cmd] that lists all sessions and returns them in a -// [listSessionsMsg]. -func (m *UI) listSessions() tea.Msg { - allSessions, _ := m.com.App.Sessions.List(context.TODO()) - return listSessionsMsg{sessions: allSessions} -} - // newSession clears the current session state and prepares for a new session. // The actual session creation happens when the user sends their first message. func (m *UI) newSession() { From 9899424bb063ddb626a6de6df603cfbdcb1118e0 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 17:06:00 -0500 Subject: [PATCH 094/335] fix(ui): dialog sessions refactor and close commands dialog on select --- internal/ui/dialog/commands.go | 3 ++- internal/ui/dialog/sessions.go | 13 ++++++++++--- internal/ui/model/ui.go | 5 ++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index f97ea7374209b26e3702722079660af0619ba229..f87547641b6b5585abcb8d5ffe77a84d8c632041 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -331,7 +331,8 @@ func (c *Commands) defaultCommands() []uicmd.Command { ID: "switch_model", Title: "Switch Model", Description: "Switch to a different model", - Shortcut: "ctrl+l", + // FIXME: The shortcut might get updated if enhanced keyboard is supported. + Shortcut: "ctrl+l", Handler: func(cmd uicmd.Command) tea.Cmd { return uiutil.CmdHandler(OpenDialogMsg{ModelsID}) }, diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index bfc107ab40225aa33ad8f3520ef818020ac90f4a..b70e4de585915c08e29d2dec5ed127b89610296a 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -1,11 +1,12 @@ package dialog import ( + "context" + "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" ) @@ -32,9 +33,14 @@ type Session struct { var _ Dialog = (*Session)(nil) // NewSessions creates a new Session dialog. -func NewSessions(com *common.Common, sessions ...session.Session) *Session { +func NewSessions(com *common.Common) (*Session, error) { s := new(Session) s.com = com + sessions, err := com.App.Sessions.List(context.TODO()) + if err != nil { + return nil, err + } + help := help.New() help.Styles = com.Styles.DialogHelpStyles() @@ -62,7 +68,8 @@ func NewSessions(com *common.Common, sessions ...session.Session) *Session { key.WithHelp("↑", "previous item"), ) s.keyMap.Close = CloseKey - return s + + return s, nil } // SetSize sets the size of the dialog. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index ea656d5c8cfe42a97c58fa163b1689b11ac228af..ff072d414b9211dbc19b5651346e7891f80e0c2e 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -540,7 +540,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { break } - m.dialog.CloseDialog(msg.DialogID) + m.dialog.CloseDialog(dialog.CommandsID) // Command dialog messages case dialog.ToggleYoloModeMsg: @@ -1436,12 +1436,11 @@ func (m *UI) openSessionsDialog() tea.Cmd { return nil } - sessions, err := m.com.App.Sessions.List(context.TODO()) + dialog, err := dialog.NewSessions(m.com) if err != nil { return uiutil.ReportError(err) } - dialog := dialog.NewSessions(m.com, sessions...) // TODO: Get. Rid. Of. Magic numbers! dialog.SetSize(min(120, m.width-8), 30) m.dialog.OpenDialog(dialog) From 34741602aab75afb82e3eba797efe6ec6d97aefc Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 19 Dec 2025 12:01:56 +0100 Subject: [PATCH 095/335] chore(chat): remove empty assistant messages --- internal/ui/AGENTS.md | 62 ++++++++++++++++++++++++++++++------ internal/ui/chat/messages.go | 6 ++-- internal/ui/list/list.go | 25 +++++++++++++++ internal/ui/model/chat.go | 24 ++++++++++++++ internal/ui/model/ui.go | 12 ++++--- 5 files changed, 112 insertions(+), 17 deletions(-) diff --git a/internal/ui/AGENTS.md b/internal/ui/AGENTS.md index fef22d3df835f38efb2265c0021ff1beefed5714..6a5ce8eb2a6104e6462d57dd52e47399bc9cc773 100644 --- a/internal/ui/AGENTS.md +++ b/internal/ui/AGENTS.md @@ -1,17 +1,59 @@ # UI Development Instructions -## General guideline -- Never use commands to send messages when you can directly mutate children or state -- Keep things simple do not overcomplicated -- Create files if needed to separate logic do not nest models +## 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. -## Big model -Keep most of the logic and state in the main model `internal/ui/model/ui.go`. +## 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 +- Layout calculations are performed +- Dialogs are orchestrated -## When working on components -Whenever you work on components make them dumb they should not handle bubble tea messages they should have methods. +### 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` -## When adding logic that has to do with the chat -Most of the logic with the chat should be in the chat component `internal/ui/model/chat.go`, keep individual items dumb and handle logic in this component. +### 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 +- `list/` - Generic list component with lazy rendering +- `common/` - Shared utilities and the Common struct +- `styles/` - All style definitions +- `anim/` - Animation system +- `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 diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 6bcb3a1c1fef2353f77329dd8814e277367fa948..f75a9f9328ff01809208bc30a58b3531e766033d 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -161,7 +161,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m return []MessageItem{NewUserMessageItem(sty, msg)} case message.Assistant: var items []MessageItem - if shouldRenderAssistantMessage(msg) { + if ShouldRenderAssistantMessage(msg) { items = append(items, NewAssistantMessageItem(sty, msg)) } for _, tc := range msg.ToolCalls() { @@ -181,11 +181,11 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m return []MessageItem{} } -// shouldRenderAssistantMessage determines if an assistant message should be rendered +// ShouldRenderAssistantMessage determines if an assistant message should be rendered // // In some cases the assistant message only has tools so we do not want to render an // empty message. -func shouldRenderAssistantMessage(msg *message.Message) bool { +func ShouldRenderAssistantMessage(msg *message.Message) bool { content := strings.TrimSpace(msg.Content().Text) thinking := strings.TrimSpace(msg.ReasoningContent().Thinking) isError := msg.FinishReason() == message.FinishReasonError diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index fddf0538a13b9adfce781ded62d1179d9fb609a5..0d0ea18186546bbc017819cbee445a04c913d0bb 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -304,6 +304,31 @@ func (l *List) AppendItems(items ...Item) { l.items = append(l.items, items...) } +// RemoveItem removes the item at the given index from the list. +func (l *List) RemoveItem(idx int) { + if idx < 0 || idx >= len(l.items) { + return + } + + // Remove the item + l.items = append(l.items[:idx], l.items[idx+1:]...) + + // Adjust selection if needed + if l.selectedIdx == idx { + l.selectedIdx = -1 + } else if l.selectedIdx > idx { + l.selectedIdx-- + } + + // Adjust offset if needed + if l.offsetIdx > idx { + l.offsetIdx-- + } else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) { + l.offsetIdx = max(0, len(l.items)-1) + l.offsetLine = 0 + } +} + // Focus sets the focus state of the list. func (l *List) Focus() { l.focused = true diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 94a999cd75b5e3853cb173c2f287b0dfba3513f7..9c11a2d512d8355d66ad71c72db1eda078ecf85c 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -245,6 +245,30 @@ func (m *Chat) ClearMessages() { m.ClearMouse() } +// RemoveMessage removes a message from the chat list by its ID. +func (m *Chat) RemoveMessage(id string) { + idx, ok := m.idInxMap[id] + if !ok { + return + } + + // Remove from list + m.list.RemoveItem(idx) + + // Remove from index map + delete(m.idInxMap, id) + + // Rebuild index map for all items after the removed one + for i := idx; i < m.list.Len(); i++ { + if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok { + m.idInxMap[item.ID()] = i + } + } + + // Clean up any paused animations for this message + delete(m.pausedAnimations, id) +} + // MessageItem returns the message item with the given ID, or nil if not found. func (m *Chat) MessageItem(id string) chat.MessageItem { idx, ok := m.idInxMap[id] diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index ff072d414b9211dbc19b5651346e7891f80e0c2e..ca79415617ebce0babbe881101402d9424743f03 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -431,12 +431,16 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { var cmds []tea.Cmd existingItem := m.chat.MessageItem(msg.ID) - if existingItem == nil || msg.Role != message.Assistant { - return nil + + if existingItem != nil { + if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok { + assistantItem.SetMessage(&msg) + } } - if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok { - assistantItem.SetMessage(&msg) + // if the message of the assistant does not have any response just tool calls we need to remove it + if !chat.ShouldRenderAssistantMessage(&msg) && len(msg.ToolCalls()) > 0 && existingItem != nil { + m.chat.RemoveMessage(msg.ID) } var items []chat.MessageItem From 2f1feaa17247968b07c722b78fc2cc71ff33d929 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 19 Dec 2025 12:20:18 +0100 Subject: [PATCH 096/335] refactor(chat): add bash related tools --- internal/ui/chat/bash.go | 222 ++++++++++++++++++++++++------ internal/ui/chat/tools.go | 4 + internal/ui/dialog/models_list.go | 6 +- internal/ui/styles/styles.go | 7 +- 4 files changed, 193 insertions(+), 46 deletions(-) diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index c57d8e8d8a3ba0f567373a81707b6fdc54166fa6..19a8d7edb4898a1861ed5045f3fb4979b8e98325 100644 --- a/internal/ui/chat/bash.go +++ b/internal/ui/chat/bash.go @@ -1,14 +1,22 @@ package chat import ( + "cmp" "encoding/json" + "fmt" "strings" + "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" ) +// ----------------------------------------------------------------------------- +// Bash Tool +// ----------------------------------------------------------------------------- + // BashToolMessageItem is a message item that represents a bash tool call. type BashToolMessageItem struct { *baseToolMessageItem @@ -23,86 +31,218 @@ func NewBashToolMessageItem( result *message.ToolResult, canceled bool, ) ToolMessageItem { - return newBaseToolMessageItem( - sty, - toolCall, - result, - &BashToolRenderContext{}, - canceled, - ) + return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled) } -// BashToolRenderContext holds context for rendering bash tool messages. -// -// It implements the [ToolRenderer] interface. +// BashToolRenderContext renders bash tool messages. type BashToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - const toolName = "Bash" if !opts.ToolCall.Finished && !opts.Canceled { - return pendingTool(sty, toolName, opts.Anim) + return pendingTool(sty, "Bash", opts.Anim) } var params tools.BashParams - var cmd string - err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) - - if err != nil { - cmd = "failed to parse command" - } else { - cmd = strings.ReplaceAll(params.Command, "\n", " ") - cmd = strings.ReplaceAll(cmd, "\t", " ") + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + params.Command = "failed to parse command" } - // TODO: if the tool is being run in the background use the background job renderer + // Check if this is a background job. + var meta tools.BashResponseMetadata + if opts.Result != nil { + _ = json.Unmarshal([]byte(opts.Result.Metadata), &meta) + } - toolParams := []string{ - cmd, + 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) } + // Regular bash command. + cmd := strings.ReplaceAll(params.Command, "\n", " ") + cmd = strings.ReplaceAll(cmd, "\t", " ") + toolParams := []string{cmd} if params.RunInBackground { toolParams = append(toolParams, "background", "true") } header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...) - if opts.Nested { return header } - earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth) - - // If this is OK that means that the tool is not done yet or it was canceled - if ok { - return strings.Join([]string{header, "", earlyStateContent}, "\n") + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) } if opts.Result == nil { - // We should not get here! return header } - var meta tools.BashResponseMetadata - err = json.Unmarshal([]byte(opts.Result.Metadata), &meta) - - var output string - if err != nil { - output = "failed to parse output" - } - output = meta.Output + output := meta.Output if output == "" && opts.Result.Content != tools.BashNoOutput { output = opts.Result.Content } - if output == "" { return header } bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Job Output Tool +// ----------------------------------------------------------------------------- + +// JobOutputToolMessageItem is a message item for job_output tool calls. +type JobOutputToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*JobOutputToolMessageItem)(nil) + +// NewJobOutputToolMessageItem creates a new [JobOutputToolMessageItem]. +func NewJobOutputToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled) +} + +// JobOutputToolRenderContext renders job_output tool messages. +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.ToolCall.Finished && !opts.Canceled { + 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) + } + + var description string + if opts.Result != nil && opts.Result.Metadata != "" { + var meta tools.JobOutputResponseMetadata + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { + description = cmp.Or(meta.Description, meta.Command) + } + } + + content := "" + if opts.Result != nil { + content = opts.Result.Content + } + return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content) +} + +// ----------------------------------------------------------------------------- +// Job Kill Tool +// ----------------------------------------------------------------------------- + +// JobKillToolMessageItem is a message item for job_kill tool calls. +type JobKillToolMessageItem struct { + *baseToolMessageItem +} - output = sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded)) +var _ ToolMessageItem = (*JobKillToolMessageItem)(nil) + +// NewJobKillToolMessageItem creates a new [JobKillToolMessageItem]. +func NewJobKillToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled) +} + +// JobKillToolRenderContext renders job_kill tool messages. +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.ToolCall.Finished && !opts.Canceled { + 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) + } + + var description string + if opts.Result != nil && opts.Result.Metadata != "" { + var meta tools.JobKillResponseMetadata + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { + description = cmp.Or(meta.Description, meta.Command) + } + } + + content := "" + if opts.Result != nil { + content = opts.Result.Content + } + return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content) +} + +// renderJobTool renders a job-related tool with the common pattern: +// header → nested check → early state → body. +func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string { + header := jobHeader(sty, opts.Status(), action, shellID, description, width) + if opts.Nested { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + return joinToolParts(header, earlyState) + } + + if content == "" { + return header + } + + bodyWidth := width - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) +} + +// jobHeader builds a header for job-related tools. +// Format: "● Job (Action) PID shellID description..." +func jobHeader(sty *styles.Styles, status ToolStatus, action, shellID, description string, width int) string { + icon := toolIcon(sty, status) + jobPart := sty.Tool.JobToolName.Render("Job") + actionPart := sty.Tool.JobAction.Render("(" + action + ")") + pidPart := sty.Tool.JobPID.Render("PID " + shellID) + + prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart) + + if description == "" { + return prefix + } + + prefixWidth := lipgloss.Width(prefix) + availableWidth := width - prefixWidth - 1 + if availableWidth < 10 { + return prefix + } + + truncatedDesc := ansi.Truncate(description, availableWidth, "…") + return prefix + " " + sty.Tool.JobDescription.Render(truncatedDesc) +} - return strings.Join([]string{header, "", output}, "\n") +// joinToolParts joins header and body with a blank line separator. +func joinToolParts(header, body string) string { + return strings.Join([]string{header, "", body}, "\n") } diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 705462e2c10c049bccb7c2237e98b20a8f03477e..fb2f260e6b74493c0b7a6168ef219ecfee40176c 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -158,6 +158,10 @@ func NewToolMessageItem( switch toolCall.Name { case tools.BashToolName: return NewBashToolMessageItem(sty, toolCall, result, canceled) + case tools.JobOutputToolName: + return NewJobOutputToolMessageItem(sty, toolCall, result, canceled) + case tools.JobKillToolName: + return NewJobKillToolMessageItem(sty, toolCall, result, canceled) default: // TODO: Implement other tool items return newBaseToolMessageItem( diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index c5707f65412c7fd2d5932377d07ab5b4d42467a3..92105d717be7323e745373b59ee205b2b13f7267 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -47,7 +47,7 @@ func (f *ModelsList) SetGroups(groups ...ModelGroup) { // Add a space separator after each provider section items = append(items, list.NewSpacerItem(1)) } - f.List.SetItems(items...) + f.SetItems(items...) } // SetFilter sets the filter query and updates the list items. @@ -66,7 +66,7 @@ func (f *ModelsList) SetSelectedItem(itemID string) { for _, g := range f.groups { for _, item := range g.Items { if item.ID() == itemID { - f.List.SetSelected(count) + f.SetSelected(count) return } count++ @@ -142,7 +142,7 @@ func (f *ModelsList) VisibleItems() []list.Item { // Render renders the filterable list. func (f *ModelsList) Render() string { - f.List.SetItems(f.VisibleItems()...) + f.SetItems(f.VisibleItems()...) return f.List.Render() } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index a91e3810142e259dfd38ad7827e9577699a112ae..854599184d50535afedfce472a3329b410ab52d1 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -160,6 +160,7 @@ type Styles struct { White color.Color BlueLight color.Color Blue color.Color + BlueDark color.Color Green color.Color GreenDark color.Color Red color.Color @@ -382,6 +383,7 @@ func DefaultStyles() Styles { blueLight = charmtone.Sardine blue = charmtone.Malibu + blueDark = charmtone.Damson // yellow = charmtone.Mustard yellow = charmtone.Mustard @@ -424,6 +426,7 @@ func DefaultStyles() Styles { s.White = white s.BlueLight = blueLight s.Blue = blue + s.BlueDark = blueDark s.Green = green s.GreenDark = greenDark s.Red = red @@ -992,8 +995,8 @@ func DefaultStyles() Styles { s.Tool.JobIconError = base.Foreground(redDark) s.Tool.JobIconSuccess = base.Foreground(green) s.Tool.JobToolName = base.Foreground(blue) - s.Tool.JobAction = base.Foreground(fgHalfMuted) - s.Tool.JobPID = s.Subtle + s.Tool.JobAction = base.Foreground(blueDark) + s.Tool.JobPID = s.Muted s.Tool.JobDescription = s.Subtle // Agent task styles From df22d89a0ebfa2871dc089abde20dd1b5ab9326c Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 19 Dec 2025 12:41:11 +0100 Subject: [PATCH 097/335] refactor(chat): add file tools --- internal/ui/chat/bash.go | 2 +- internal/ui/chat/file.go | 278 +++++++++++++++++++++++++++++++++++ internal/ui/chat/tools.go | 192 +++++++++++++++++++++++- internal/ui/styles/styles.go | 18 ++- 4 files changed, 479 insertions(+), 11 deletions(-) create mode 100644 internal/ui/chat/file.go diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index 19a8d7edb4898a1861ed5045f3fb4979b8e98325..000d83b8b1f7d1b06b113f3b92e1c6fc0ec4e32e 100644 --- a/internal/ui/chat/bash.go +++ b/internal/ui/chat/bash.go @@ -69,7 +69,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "background", "true") } - header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...) + header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Nested, toolParams...) if opts.Nested { return header } diff --git a/internal/ui/chat/file.go b/internal/ui/chat/file.go new file mode 100644 index 0000000000000000000000000000000000000000..255b0df3bac9455cd81f7c9ae882e4a2ae1d368a --- /dev/null +++ b/internal/ui/chat/file.go @@ -0,0 +1,278 @@ +package chat + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// View Tool +// ----------------------------------------------------------------------------- + +// ViewToolMessageItem is a message item that represents a view tool call. +type ViewToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*ViewToolMessageItem)(nil) + +// NewViewToolMessageItem creates a new [ViewToolMessageItem]. +func NewViewToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &ViewToolRenderContext{}, canceled) +} + +// ViewToolRenderContext renders view tool messages. +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.ToolCall.Finished && !opts.Canceled { + 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) + } + + file := fsext.PrettyPath(params.FilePath) + toolParams := []string{file} + if params.Limit != 0 { + toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit)) + } + if params.Offset != 0 { + toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset)) + } + + header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Nested, toolParams...) + if opts.Nested { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil { + return header + } + + // Handle image content. + if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") { + body := toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType) + return joinToolParts(header, body) + } + + // Try to get content from metadata first (contains actual file content). + var meta tools.ViewResponseMetadata + content := opts.Result.Content + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil && meta.Content != "" { + content = meta.Content + } + + if content == "" { + return header + } + + // Render code content with syntax highlighting. + body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, cappedWidth, opts.Expanded) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Write Tool +// ----------------------------------------------------------------------------- + +// WriteToolMessageItem is a message item that represents a write tool call. +type WriteToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*WriteToolMessageItem)(nil) + +// NewWriteToolMessageItem creates a new [WriteToolMessageItem]. +func NewWriteToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &WriteToolRenderContext{}, canceled) +} + +// WriteToolRenderContext renders write tool messages. +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.ToolCall.Finished && !opts.Canceled { + 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) + } + + file := fsext.PrettyPath(params.FilePath) + header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Nested, file) + if opts.Nested { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if params.Content == "" { + return header + } + + // Render code content with syntax highlighting. + body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.Expanded) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Edit Tool +// ----------------------------------------------------------------------------- + +// EditToolMessageItem is a message item that represents an edit tool call. +type EditToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*EditToolMessageItem)(nil) + +// NewEditToolMessageItem creates a new [EditToolMessageItem]. +func NewEditToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &EditToolRenderContext{}, canceled) +} + +// EditToolRenderContext renders edit tool messages. +type EditToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + // Edit tool uses full width for diffs. + if !opts.ToolCall.Finished && !opts.Canceled { + return pendingTool(sty, "Edit", opts.Anim) + } + + var params tools.EditParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + } + + file := fsext.PrettyPath(params.FilePath) + header := toolHeader(sty, opts.Status(), "Edit", width, opts.Nested, file) + if opts.Nested { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil { + return header + } + + // Get diff content from metadata. + var meta tools.EditResponseMetadata + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil { + bodyWidth := width - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) + } + + // Render diff. + body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.Expanded) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// MultiEdit Tool +// ----------------------------------------------------------------------------- + +// MultiEditToolMessageItem is a message item that represents a multi-edit tool call. +type MultiEditToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*MultiEditToolMessageItem)(nil) + +// NewMultiEditToolMessageItem creates a new [MultiEditToolMessageItem]. +func NewMultiEditToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &MultiEditToolRenderContext{}, canceled) +} + +// MultiEditToolRenderContext renders multi-edit tool messages. +type MultiEditToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + // MultiEdit tool uses full width for diffs. + if !opts.ToolCall.Finished && !opts.Canceled { + return pendingTool(sty, "Multi-Edit", opts.Anim) + } + + var params tools.MultiEditParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + } + + file := fsext.PrettyPath(params.FilePath) + toolParams := []string{file} + if len(params.Edits) > 0 { + toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits))) + } + + header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Nested, toolParams...) + if opts.Nested { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil { + return header + } + + // Get diff content from metadata. + var meta tools.MultiEditResponseMetadata + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil { + bodyWidth := width - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) + } + + // Render diff with optional failed edits note. + body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.Expanded) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index fb2f260e6b74493c0b7a6168ef219ecfee40176c..ef6a9d66b8aeacdcdaef252157bf6be3bca44ecd 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/x/ansi" ) @@ -162,6 +163,14 @@ func NewToolMessageItem( return NewJobOutputToolMessageItem(sty, toolCall, result, canceled) case tools.JobKillToolName: return NewJobKillToolMessageItem(sty, toolCall, result, canceled) + case tools.ViewToolName: + return NewViewToolMessageItem(sty, toolCall, result, canceled) + case tools.WriteToolName: + return NewWriteToolMessageItem(sty, toolCall, result, canceled) + case tools.EditToolName: + return NewEditToolMessageItem(sty, toolCall, result, canceled) + case tools.MultiEditToolName: + return NewMultiEditToolMessageItem(sty, toolCall, result, canceled) default: // TODO: Implement other tool items return newBaseToolMessageItem( @@ -376,9 +385,13 @@ func toolParamList(sty *styles.Styles, params []string, width int) string { } // toolHeader builds the tool header line: "● ToolName params..." -func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, params ...string) string { +func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string { icon := toolIcon(sty, status) - toolName := sty.Tool.NameNested.Render(name) + nameStyle := sty.Tool.NameNormal + if nested { + nameStyle = sty.Tool.NameNested + } + toolName := nameStyle.Render(name) prefix := fmt.Sprintf("%s %s ", icon, toolName) prefixWidth := lipgloss.Width(prefix) remainingWidth := width - prefixWidth @@ -420,3 +433,178 @@ func toolOutputPlainContent(sty *styles.Styles, content string, width int, expan return strings.Join(out, "\n") } + +// toolOutputCodeContent renders code with syntax highlighting and line numbers. +func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\t", " ") + + lines := strings.Split(content, "\n") + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) + } + + // Truncate if needed. + displayLines := lines + if len(lines) > maxLines { + displayLines = lines[:maxLines] + } + + bg := sty.Tool.ContentCodeBg + highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg) + highlightedLines := strings.Split(highlighted, "\n") + + // Calculate line number width. + maxLineNumber := len(displayLines) + offset + maxDigits := getDigits(maxLineNumber) + numFmt := fmt.Sprintf("%%%dd", maxDigits) + + bodyWidth := width - toolBodyLeftPaddingTotal + codeWidth := bodyWidth - maxDigits - 4 // -4 for line number padding + + 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, "…") + } + + codeLine := sty.Tool.ContentCodeLine. + Width(codeWidth). + PaddingLeft(2). + Render(ln) + + out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine)) + } + + // Add truncation message if needed. + if len(lines) > maxLines && !expanded { + truncMsg := sty.Tool.ContentCodeTruncation. + Width(bodyWidth). + Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-maxLines)) + out = append(out, truncMsg) + } + + return sty.Tool.Body.Render(strings.Join(out, "\n")) +} + +// toolOutputImageContent renders image data with size info. +func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string { + dataSize := len(data) * 3 / 4 + sizeStr := formatSize(dataSize) + + loaded := sty.Base.Foreground(sty.Green).Render("Loaded") + arrow := sty.Base.Foreground(sty.GreenDark).Render("→") + typeStyled := sty.Base.Render(mediaType) + sizeStyled := sty.Subtle.Render(sizeStr) + + return sty.Tool.Body.Render(fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled)) +} + +// getDigits returns the number of digits in a number. +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 +} + +// formatSize formats byte size into human readable format. +func formatSize(bytes int) string { + const ( + kb = 1024 + mb = kb * 1024 + ) + switch { + case bytes >= mb: + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb)) + case bytes >= kb: + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) + default: + return fmt.Sprintf("%d B", bytes) + } +} + +// toolOutputDiffContent renders a diff between old and new content. +func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string { + bodyWidth := width - toolBodyLeftPaddingTotal + + formatter := common.DiffFormatter(sty). + Before(file, oldContent). + After(file, newContent). + Width(bodyWidth) + + // Use split view for wide terminals. + if width > 120 { + formatter = formatter.Split() + } + + formatted := formatter.String() + lines := strings.Split(formatted, "\n") + + // Truncate if needed. + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) + } + + if len(lines) > maxLines && !expanded { + truncMsg := sty.Tool.DiffTruncation. + Width(bodyWidth). + Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-maxLines)) + formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg + } + + return sty.Tool.Body.Render(formatted) +} + +// toolOutputMultiEditDiffContent renders a diff with optional failed edits note. +func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string { + bodyWidth := width - toolBodyLeftPaddingTotal + + formatter := common.DiffFormatter(sty). + Before(file, meta.OldContent). + After(file, meta.NewContent). + Width(bodyWidth) + + // Use split view for wide terminals. + if width > 120 { + formatter = formatter.Split() + } + + formatted := formatter.String() + lines := strings.Split(formatted, "\n") + + // Truncate if needed. + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) + } + + if len(lines) > maxLines && !expanded { + truncMsg := sty.Tool.DiffTruncation. + Width(bodyWidth). + Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-maxLines)) + formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg + } + + // Add failed edits note if any exist. + if len(meta.EditsFailed) > 0 { + noteTag := sty.Tool.NoteTag.Render("Note") + noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits) + note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg)) + formatted = formatted + "\n\n" + note + } + + return sty.Tool.Body.Render(formatted) +} diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 854599184d50535afedfce472a3329b410ab52d1..a0c7ad418a0c8d4dbeb041cfd48d5a16fd110622 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -238,11 +238,12 @@ type Styles struct { ParamKey lipgloss.Style // Parameter keys // Content rendering styles - ContentLine lipgloss.Style // Individual content line with background and width - ContentTruncation lipgloss.Style // Truncation message "… (N lines)" - ContentCodeLine lipgloss.Style // Code line with background and width - ContentCodeBg color.Color // Background color for syntax highlighting - Body lipgloss.Style // Body content padding (PaddingLeft(2)) + ContentLine lipgloss.Style // Individual content line with background and width + ContentTruncation lipgloss.Style // Truncation message "… (N lines)" + ContentCodeLine lipgloss.Style // Code line with background and width + ContentCodeTruncation lipgloss.Style // Code truncation message with bgBase + ContentCodeBg color.Color // Background color for syntax highlighting + Body lipgloss.Style // Body content padding (PaddingLeft(2)) // Deprecated - kept for backward compatibility ContentBg lipgloss.Style // Content background @@ -970,14 +971,15 @@ 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(bgBaseLighter) + s.Tool.ContentCodeLine = s.Base.Background(bgBase) + s.Tool.ContentCodeTruncation = s.Muted.Background(bgBase).PaddingLeft(2) s.Tool.ContentCodeBg = bgBase s.Tool.Body = base.PaddingLeft(2) // Deprecated - kept for backward compatibility s.Tool.ContentBg = s.Muted.Background(bgBaseLighter) s.Tool.ContentText = s.Muted - s.Tool.ContentLineNumber = s.Subtle + s.Tool.ContentLineNumber = base.Foreground(fgMuted).Background(bgBase).PaddingRight(1).PaddingLeft(1) s.Tool.StateWaiting = base.Foreground(fgSubtle) s.Tool.StateCancelled = base.Foreground(fgSubtle) @@ -987,7 +989,7 @@ func DefaultStyles() Styles { // Diff and multi-edit styles s.Tool.DiffTruncation = s.Muted.Background(bgBaseLighter).PaddingLeft(2) - s.Tool.NoteTag = base.Padding(0, 1).Background(yellow).Foreground(white) + s.Tool.NoteTag = base.Padding(0, 1).Background(info).Foreground(white) s.Tool.NoteMessage = base.Foreground(fgHalfMuted) // Job header styles From 5699a91ad5573613fb72bc8f3dc73a8ae739ccf8 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 19 Dec 2025 13:02:15 +0100 Subject: [PATCH 098/335] refactor(chat): add search tools --- internal/ui/chat/search.go | 194 +++++++++++++++++++++++++++++++++++++ internal/ui/chat/tools.go | 6 ++ 2 files changed, 200 insertions(+) create mode 100644 internal/ui/chat/search.go diff --git a/internal/ui/chat/search.go b/internal/ui/chat/search.go new file mode 100644 index 0000000000000000000000000000000000000000..6fa907d2c1f37dc17b62a925d720a52048f1f342 --- /dev/null +++ b/internal/ui/chat/search.go @@ -0,0 +1,194 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// Glob Tool +// ----------------------------------------------------------------------------- + +// GlobToolMessageItem is a message item that represents a glob tool call. +type GlobToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*GlobToolMessageItem)(nil) + +// NewGlobToolMessageItem creates a new [GlobToolMessageItem]. +func NewGlobToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &GlobToolRenderContext{}, canceled) +} + +// GlobToolRenderContext renders glob tool messages. +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.ToolCall.Finished && !opts.Canceled { + 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) + } + + toolParams := []string{params.Pattern} + if params.Path != "" { + toolParams = append(toolParams, "path", params.Path) + } + + header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Nested, toolParams...) + if opts.Nested { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil || opts.Result.Content == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Grep Tool +// ----------------------------------------------------------------------------- + +// GrepToolMessageItem is a message item that represents a grep tool call. +type GrepToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*GrepToolMessageItem)(nil) + +// NewGrepToolMessageItem creates a new [GrepToolMessageItem]. +func NewGrepToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &GrepToolRenderContext{}, canceled) +} + +// GrepToolRenderContext renders grep tool messages. +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.ToolCall.Finished && !opts.Canceled { + 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) + } + + toolParams := []string{params.Pattern} + if params.Path != "" { + toolParams = append(toolParams, "path", params.Path) + } + if params.Include != "" { + toolParams = append(toolParams, "include", params.Include) + } + if params.LiteralText { + toolParams = append(toolParams, "literal", "true") + } + + header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Nested, toolParams...) + if opts.Nested { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil || opts.Result.Content == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// LS Tool +// ----------------------------------------------------------------------------- + +// LSToolMessageItem is a message item that represents an ls tool call. +type LSToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*LSToolMessageItem)(nil) + +// NewLSToolMessageItem creates a new [LSToolMessageItem]. +func NewLSToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &LSToolRenderContext{}, canceled) +} + +// LSToolRenderContext renders ls tool messages. +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.ToolCall.Finished && !opts.Canceled { + 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) + } + + path := params.Path + if path == "" { + path = "." + } + path = fsext.PrettyPath(path) + + header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Nested, path) + if opts.Nested { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil || opts.Result.Content == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index ef6a9d66b8aeacdcdaef252157bf6be3bca44ecd..40a3d0307be2080d4dd7991939303a409731bd64 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -171,6 +171,12 @@ func NewToolMessageItem( return NewEditToolMessageItem(sty, toolCall, result, canceled) case tools.MultiEditToolName: return NewMultiEditToolMessageItem(sty, toolCall, result, canceled) + case tools.GlobToolName: + return NewGlobToolMessageItem(sty, toolCall, result, canceled) + case tools.GrepToolName: + return NewGrepToolMessageItem(sty, toolCall, result, canceled) + case tools.LSToolName: + return NewLSToolMessageItem(sty, toolCall, result, canceled) default: // TODO: Implement other tool items return newBaseToolMessageItem( From 4665ad27daeb777fe519f7d8057c4dfea7923e9d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 19 Dec 2025 12:07:40 -0500 Subject: [PATCH 099/335] chore(ui): standardize truncation message format --- internal/ui/chat/assistant.go | 8 ++++++-- internal/ui/chat/tools.go | 14 +++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 5efa9c8b6a72aa9644f34618ad50b89a2aae3913..91849673c3a9eea603c85c54a7c5ac77d759c2ae 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -13,6 +13,10 @@ import ( "github.com/charmbracelet/x/ansi" ) +// assistantMessageTruncateFormat is the text shown when an assistant message is +// truncated. +const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to expand]" + // maxCollapsedThinkingHeight defines the maximum height of the thinking const maxCollapsedThinkingHeight = 10 @@ -153,9 +157,9 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string if !a.thinkingExpanded && isTruncated { lines = lines[totalLines-maxCollapsedThinkingHeight:] hint := a.sty.Chat.Message.ThinkingTruncationHint.Render( - fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight), + fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight), ) - lines = append(lines, "", hint) + lines = append([]string{hint, ""}, lines...) } thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width) diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 40a3d0307be2080d4dd7991939303a409731bd64..fb3ab943e3d5798278ded7f377657d4dd4e420e4 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -434,7 +434,7 @@ func toolOutputPlainContent(sty *styles.Styles, content string, width int, expan if !expanded && wasTruncated { out = append(out, sty.Tool.ContentTruncation. Width(width). - Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight))) + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-responseContextHeight))) } return strings.Join(out, "\n") @@ -489,8 +489,8 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid if len(lines) > maxLines && !expanded { truncMsg := sty.Tool.ContentCodeTruncation. Width(bodyWidth). - Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-maxLines)) - out = append(out, truncMsg) + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)) + out = append([]string{truncMsg}, out...) } return sty.Tool.Body.Render(strings.Join(out, "\n")) @@ -567,8 +567,8 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri if len(lines) > maxLines && !expanded { truncMsg := sty.Tool.DiffTruncation. Width(bodyWidth). - Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-maxLines)) - formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)) + formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n") } return sty.Tool.Body.Render(formatted) @@ -600,8 +600,8 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools. if len(lines) > maxLines && !expanded { truncMsg := sty.Tool.DiffTruncation. Width(bodyWidth). - Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-maxLines)) - formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)) + formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n") } // Add failed edits note if any exist. From 098bc8abf07fd8dd0506669376c34e6445a07f10 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sun, 28 Dec 2025 14:15:52 +0100 Subject: [PATCH 100/335] fix(sessions): select the current session in dialog --- internal/ui/dialog/sessions.go | 25 ++++++++++++++++++------- internal/ui/model/ui.go | 7 ++++++- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index b70e4de585915c08e29d2dec5ed127b89610296a..daeec6e208dee88883b7d86282769093fef67858 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -16,11 +16,12 @@ const SessionsID = "session" // Session is a session selector dialog. type Session struct { - width, height int - com *common.Common - help help.Model - list *list.FilterableList - input textinput.Model + width, height int + com *common.Common + help help.Model + list *list.FilterableList + input textinput.Model + selectedSessionInx int keyMap struct { Select key.Binding @@ -33,7 +34,7 @@ type Session struct { var _ Dialog = (*Session)(nil) // NewSessions creates a new Session dialog. -func NewSessions(com *common.Common) (*Session, error) { +func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) { s := new(Session) s.com = com sessions, err := com.App.Sessions.List(context.TODO()) @@ -41,13 +42,19 @@ func NewSessions(com *common.Common) (*Session, error) { return nil, err } + for i, sess := range sessions { + if sess.ID == selectedSessionID { + s.selectedSessionInx = i + break + } + } + help := help.New() help.Styles = com.Styles.DialogHelpStyles() s.help = help s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...) s.list.Focus() - s.list.SetSelected(0) s.input = textinput.New() s.input.SetVirtualCursor(false) @@ -85,6 +92,10 @@ func (s *Session) SetSize(width, height int) { s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding s.list.SetSize(innerWidth, height-heightOffset) s.help.SetWidth(width) + + // Now that we know the height we can select the selected session and scroll to it. + s.list.SetSelected(s.selectedSessionInx) + s.list.ScrollToSelected() } // ID implements Dialog. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index ca79415617ebce0babbe881101402d9424743f03..d7df5940c89dfeb595a5a7aa1077fb3d56e1324a 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1440,7 +1440,12 @@ func (m *UI) openSessionsDialog() tea.Cmd { return nil } - dialog, err := dialog.NewSessions(m.com) + selectedSessionID := "" + if m.session != nil { + selectedSessionID = m.session.ID + } + + dialog, err := dialog.NewSessions(m.com, selectedSessionID) if err != nil { return uiutil.ReportError(err) } From 876048d8b45fdbe0a57c6e0a7db8f5f9da2dac32 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sun, 28 Dec 2025 14:25:01 +0100 Subject: [PATCH 101/335] fix(ui): do not allow summarizing if agent is busy --- internal/ui/model/ui.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index d7df5940c89dfeb595a5a7aa1077fb3d56e1324a..981d6f56aeef564539ed2a976299fa87aa03e96a 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -560,6 +560,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.newSession() m.dialog.CloseDialog(dialog.CommandsID) case dialog.CompactMsg: + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session...")) + break + } err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) if err != nil { cmds = append(cmds, uiutil.ReportError(err)) From 5df46965eb6221b8a524f0c8a3b0d6c5b61f71fb Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 5 Jan 2026 11:28:00 -0500 Subject: [PATCH 102/335] fix(ui): model dialog: skip non-model items when navigating selection --- internal/ui/dialog/models_list.go | 73 ++++++++++++++++++++++++++++++- internal/ui/model/ui.go | 2 +- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index 92105d717be7323e745373b59ee205b2b13f7267..bf17e136366252c5862af1b8d7420c4b3be943ee 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -55,6 +55,28 @@ func (f *ModelsList) SetFilter(q string) { f.query = q } +// SetSelected sets the selected item index. It overrides the base method to +// skip non-model items. +func (f *ModelsList) SetSelected(index int) { + if index < 0 || index >= f.Len() { + f.List.SetSelected(index) + return + } + + f.List.SetSelected(index) + for { + selectedItem := f.List.SelectedItem() + if _, ok := selectedItem.(*ModelItem); ok { + return + } + f.List.SetSelected(index + 1) + index++ + if index >= f.Len() { + return + } + } +} + // SetSelectedItem sets the selected item in the list by item ID. func (f *ModelsList) SetSelectedItem(itemID string) { if itemID == "" { @@ -74,14 +96,63 @@ func (f *ModelsList) SetSelectedItem(itemID string) { } } +// SelectNext selects the next model item, skipping any non-focusable items +// like group headers and spacers. +func (f *ModelsList) SelectNext() (v bool) { + for { + v = f.List.SelectNext() + selectedItem := f.List.SelectedItem() + if _, ok := selectedItem.(*ModelItem); ok { + return v + } + } +} + +// SelectPrev selects the previous model item, skipping any non-focusable items +// like group headers and spacers. +func (f *ModelsList) SelectPrev() (v bool) { + for { + v = f.List.SelectPrev() + selectedItem := f.List.SelectedItem() + if _, ok := selectedItem.(*ModelItem); ok { + return v + } + } +} + +// SelectFirst selects the first model item in the list. +func (f *ModelsList) SelectFirst() (v bool) { + v = f.List.SelectFirst() + for { + selectedItem := f.List.SelectedItem() + if _, ok := selectedItem.(*ModelItem); ok { + return v + } + v = f.List.SelectNext() + } +} + +// SelectLast selects the last model item in the list. +func (f *ModelsList) SelectLast() (v bool) { + v = f.List.SelectLast() + for { + selectedItem := f.List.SelectedItem() + if _, ok := selectedItem.(*ModelItem); ok { + return v + } + v = f.List.SelectPrev() + } +} + // VisibleItems returns the visible items after filtering. func (f *ModelsList) VisibleItems() []list.Item { - if len(f.query) == 0 { + if f.query == "" { // No filter, return all items with group headers items := []list.Item{} for _, g := range f.groups { items = append(items, &g) for _, item := range g.Items { + item.SetMatch(fuzzy.Match{}) items = append(items, item) } // Add a space separator after each provider section diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 981d6f56aeef564539ed2a976299fa87aa03e96a..a52339208f64a2d628803af97bd3aef92965af5a 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -728,7 +728,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { return tea.Batch(cmds...) } -// Draw implements [tea.Layer] and draws the UI model. +// Draw implements [uv.Drawable] and draws the UI model. func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { layout := m.generateLayout(area.Dx(), area.Dy()) From 90491b4b8ae5be3605cd92d77418edfd5aa4f3e1 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 5 Jan 2026 12:45:48 -0500 Subject: [PATCH 103/335] feat(ui): model dialog: implement model selection handling --- internal/config/config.go | 5 ++++ internal/ui/dialog/messages.go | 6 ++-- internal/ui/dialog/models.go | 48 ++++++++++++++++++++----------- internal/ui/dialog/models_item.go | 24 ++++++++++++++-- internal/ui/model/ui.go | 22 +++++++++++++- 5 files changed, 81 insertions(+), 24 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index e68ad8c27ca7e3c2313a3b18b48bcbedc3d677e9..22f5ea87be2676920e49b472565c4aaf7425c52c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,6 +54,11 @@ var defaultContextPaths = []string{ type SelectedModelType string +// String returns the string representation of the [SelectedModelType]. +func (s SelectedModelType) String() string { + return string(s) +} + const ( SelectedModelTypeLarge SelectedModelType = "large" SelectedModelTypeSmall SelectedModelType = "small" diff --git a/internal/ui/dialog/messages.go b/internal/ui/dialog/messages.go index 2d69e4c9b841fcc9d8776e8e6fb5cf04e3d1d0f0..8efc59240e83ea8137cdaf14a7c87f903b8683b5 100644 --- a/internal/ui/dialog/messages.go +++ b/internal/ui/dialog/messages.go @@ -2,7 +2,7 @@ package dialog import ( tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/session" ) @@ -24,8 +24,8 @@ type SessionSelectedMsg struct { // ModelSelectedMsg is a message indicating a model has been selected. type ModelSelectedMsg struct { - Provider catwalk.Provider - Model catwalk.Model + Model config.SelectedModel + ModelType config.SelectedModelType } // Messages for commands diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 47b50cd758d68e84c1b0615b0b98b237ebbbcdb6..f0e0cb3b3e94f5749fe249b2758561f8ea615a9a 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -37,6 +37,30 @@ func (mt ModelType) String() string { } } +// Config returns the corresponding config model type. +func (mt ModelType) Config() config.SelectedModelType { + switch mt { + case ModelTypeLarge: + return config.SelectedModelTypeLarge + case ModelTypeSmall: + return config.SelectedModelTypeSmall + default: + return "" + } +} + +// Placeholder returns the input placeholder for the model type. +func (mt ModelType) Placeholder() string { + switch mt { + case ModelTypeLarge: + return largeModelInputPlaceholder + case ModelTypeSmall: + return smallModelInputPlaceholder + default: + return "" + } +} + const ( largeModelInputPlaceholder = "Choose a model for large, complex tasks" smallModelInputPlaceholder = "Choose a model for small, simple tasks" @@ -180,8 +204,8 @@ func (m *Models) Update(msg tea.Msg) tea.Msg { } return ModelSelectedMsg{ - Provider: modelItem.prov, - Model: modelItem.model, + Model: modelItem.SelectedModel(), + ModelType: modelItem.SelectedModelType(), } case key.Matches(msg, m.keyMap.Tab): if m.modelType == ModelTypeLarge { @@ -276,14 +300,8 @@ func (m *Models) setProviderItems() error { t := m.com.Styles cfg := m.com.Config() - selectedType := config.SelectedModelTypeLarge - if m.modelType == ModelTypeLarge { - selectedType = config.SelectedModelTypeLarge - } else { - selectedType = config.SelectedModelTypeSmall - } - var selectedItemID string + selectedType := m.modelType.Config() currentModel := cfg.Models[selectedType] recentItems := cfg.RecentModels[selectedType] @@ -325,7 +343,7 @@ func (m *Models) setProviderItems() error { group := NewModelGroup(t, name, true) for _, model := range p.Models { - item := NewModelItem(t, provider, model, false) + item := NewModelItem(t, provider, model, m.modelType, false) group.AppendItems(item) itemsMap[item.ID()] = item if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { @@ -379,7 +397,7 @@ func (m *Models) setProviderItems() error { group := NewModelGroup(t, name, providerConfigured) for _, model := range displayProvider.Models { - item := NewModelItem(t, provider, model, false) + item := NewModelItem(t, provider, model, m.modelType, false) group.AppendItems(item) itemsMap[item.ID()] = item if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { @@ -402,7 +420,7 @@ func (m *Models) setProviderItems() error { } // Show provider for recent items - item = NewModelItem(t, item.prov, item.model, true) + item = NewModelItem(t, item.prov, item.model, m.modelType, true) item.showProvider = true validRecentItems = append(validRecentItems, recent) @@ -429,11 +447,7 @@ func (m *Models) setProviderItems() error { m.list.SetSelectedItem(selectedItemID) // Update placeholder based on model type - if m.modelType == ModelTypeLarge { - m.input.Placeholder = largeModelInputPlaceholder - } else { - m.input.Placeholder = smallModelInputPlaceholder - } + m.input.Placeholder = m.modelType.Placeholder() return nil } diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go index 46722f8592e417af5b90d6187d42a5cc11a89f7c..40a8a25c57cd7cf0ce6252ef3113ce2af2f8d2f4 100644 --- a/internal/ui/dialog/models_item.go +++ b/internal/ui/dialog/models_item.go @@ -3,6 +3,7 @@ package dialog import ( "charm.land/lipgloss/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/x/ansi" @@ -49,8 +50,9 @@ func (m *ModelGroup) Render(width int) string { // ModelItem represents a list item for a model type. type ModelItem struct { - prov catwalk.Provider - model catwalk.Model + prov catwalk.Provider + model catwalk.Model + modelType ModelType cache map[int]string t *styles.Styles @@ -59,13 +61,29 @@ type ModelItem struct { showProvider bool } +// SelectedModel returns this model item as a [config.SelectedModel] instance. +func (m *ModelItem) SelectedModel() config.SelectedModel { + return config.SelectedModel{ + Model: m.model.ID, + Provider: string(m.prov.ID), + ReasoningEffort: m.model.DefaultReasoningEffort, + MaxTokens: m.model.DefaultMaxTokens, + } +} + +// SelectedModelType returns the type of model represented by this item. +func (m *ModelItem) SelectedModelType() config.SelectedModelType { + return m.modelType.Config() +} + var _ ListItem = &ModelItem{} // NewModelItem creates a new ModelItem. -func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model, showProvider bool) *ModelItem { +func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model, typ ModelType, showProvider bool) *ModelItem { return &ModelItem{ prov: prov, model: model, + modelType: typ, t: t, cache: make(map[int]string), showProvider: showProvider, diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a52339208f64a2d628803af97bd3aef92965af5a..0f5dfecad68c00659b4c13ddd2c632844fd6e9da 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -574,7 +574,27 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { case dialog.QuitMsg: cmds = append(cmds, tea.Quit) case dialog.ModelSelectedMsg: - // TODO: Handle model switching + if m.com.App.AgentCoordinator.IsBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) + break + } + + cfg := m.com.Config() + if cfg == nil { + cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found"))) + break + } + + if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil { + cmds = append(cmds, uiutil.ReportError(err)) + } + + // XXX: Should this be in a separate goroutine? + go m.com.App.UpdateAgentModel(context.TODO()) + + modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) + cmds = append(cmds, uiutil.ReportInfo(modelMsg)) + m.dialog.CloseDialog(dialog.ModelsID) } return tea.Batch(cmds...) From 4e541e1910f3f2d8362259da1b0776f59e6a12c9 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 5 Jan 2026 12:49:11 -0500 Subject: [PATCH 104/335] chore(ui): add TODO for model API and auth validation --- internal/ui/model/ui.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 0f5dfecad68c00659b4c13ddd2c632844fd6e9da..6072c4292c07aebbd92a1329944bc20b9826bb48 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -579,6 +579,8 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { break } + // TODO: Validate model API and authentication here? + cfg := m.com.Config() if cfg == nil { cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found"))) From 03b18fe755acf77f5e10b9000ed7a2cf605af465 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 5 Jan 2026 14:41:13 -0500 Subject: [PATCH 105/335] fix(ui): list: countLines should return 1 for empty strings --- internal/ui/dialog/models_list.go | 18 ++++++++++++++++++ internal/ui/list/list.go | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index bf17e136366252c5862af1b8d7420c4b3be943ee..4cba59d0f8d33b6f7affa98603bfeed1de29cfa2 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -144,6 +144,24 @@ func (f *ModelsList) SelectLast() (v bool) { } } +// IsSelectedFirst checks if the selected item is the first model item. +func (f *ModelsList) IsSelectedFirst() bool { + originalIndex := f.List.Selected() + f.SelectFirst() + isFirst := f.List.Selected() == originalIndex + f.List.SetSelected(originalIndex) + return isFirst +} + +// IsSelectedLast checks if the selected item is the last model item. +func (f *ModelsList) IsSelectedLast() bool { + originalIndex := f.List.Selected() + f.SelectLast() + isLast := f.List.Selected() == originalIndex + f.List.SetSelected(originalIndex) + return isLast +} + // VisibleItems returns the visible items after filtering. func (f *ModelsList) VisibleItems() []list.Item { if f.query == "" { diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 0d0ea18186546bbc017819cbee445a04c913d0bb..cd2c36c0c8b99d8d9a4dca7b7e1a69a0d17705ed 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -553,7 +553,7 @@ func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) { // countLines counts the number of lines in a string. func countLines(s string) int { if s == "" { - return 0 + return 1 } return strings.Count(s, "\n") + 1 } From f5415908291cbb713943aa5f42b3f9e54a5cfa1c Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 5 Jan 2026 16:09:41 -0500 Subject: [PATCH 106/335] feat(ui): models dialog: filter by provider and model name --- internal/ui/dialog/models_list.go | 44 +++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index 4cba59d0f8d33b6f7affa98603bfeed1de29cfa2..e7a7303536e701424d29aa049af91cf977e8318a 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -1,7 +1,10 @@ package dialog import ( + "fmt" "slices" + "sort" + "strings" "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" @@ -186,16 +189,21 @@ func (f *ModelsList) VisibleItems() []list.Item { } } - matches := fuzzy.FindFrom(f.query, list.FilterableItemsSource(filterableItems)) - for _, match := range matches { - item := filterableItems[match.Index] - if ms, ok := item.(list.MatchSettable); ok { - ms.SetMatch(match) - item = ms.(list.FilterableItem) - } - filterableItems = append(filterableItems, item) + filterValue := func(itm *ModelItem) string { + return strings.ToLower(fmt.Sprintf("%s %s", itm.prov.Name, itm.model.Name)) + } + + names := make([]string, len(filterableItems)) + for i, item := range filterableItems { + ms := item.(*ModelItem) + names[i] = filterValue(ms) } + matches := fuzzy.Find(f.query, names) + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].Score > matches[j].Score + }) + items := []list.Item{} visitedGroups := map[int]bool{} @@ -203,19 +211,27 @@ func (f *ModelsList) VisibleItems() []list.Item { // Find which group this item belongs to for gi, g := range f.groups { addedCount := 0 + name := g.Title + " " for _, match := range matches { - item := filterableItems[match.Index] - if slices.Contains(g.Items, item.(*ModelItem)) { + item := filterableItems[match.Index].(*ModelItem) + idxs := []int{} + for _, idx := range match.MatchedIndexes { + // Adjusts removing provider name highlights + if idx < len(name) { + continue + } + idxs = append(idxs, idx-len(name)) + } + + match.MatchedIndexes = idxs + if slices.Contains(g.Items, item) { if !visitedGroups[gi] { // Add section header items = append(items, &g) visitedGroups[gi] = true } // Add the matched item - if ms, ok := item.(list.MatchSettable); ok { - ms.SetMatch(match) - item = ms.(list.FilterableItem) - } + item.SetMatch(match) items = append(items, item) addedCount++ } From c280dd546834d6f54c74ea0484c49fc0c5920caf Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 5 Jan 2026 16:13:09 -0500 Subject: [PATCH 107/335] feat(ui): models dialog: improve group filtering by ignoring spaces Related: 861db845 --- internal/ui/dialog/models_list.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index e7a7303536e701424d29aa049af91cf977e8318a..6903804e0868128953e2ab1a63b7b5963ec69ff7 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -167,7 +167,9 @@ func (f *ModelsList) IsSelectedLast() bool { // VisibleItems returns the visible items after filtering. func (f *ModelsList) VisibleItems() []list.Item { - if f.query == "" { + query := strings.ToLower(strings.ReplaceAll(f.query, " ", "")) + + if query == "" { // No filter, return all items with group headers items := []list.Item{} for _, g := range f.groups { @@ -199,7 +201,7 @@ func (f *ModelsList) VisibleItems() []list.Item { names[i] = filterValue(ms) } - matches := fuzzy.Find(f.query, names) + matches := fuzzy.Find(query, names) sort.SliceStable(matches, func(i, j int) bool { return matches[i].Score > matches[j].Score }) From 909244e4706a2408acf625ce38a7c5592c9322aa Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 5 Jan 2026 16:21:18 -0500 Subject: [PATCH 108/335] fix(ui): models dialog: filter each group separately --- internal/ui/dialog/models_list.go | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index 6903804e0868128953e2ab1a63b7b5963ec69ff7..61541277e3b4e81aac9e839a6e3a52480347e31a 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -191,21 +191,6 @@ func (f *ModelsList) VisibleItems() []list.Item { } } - filterValue := func(itm *ModelItem) string { - return strings.ToLower(fmt.Sprintf("%s %s", itm.prov.Name, itm.model.Name)) - } - - names := make([]string, len(filterableItems)) - for i, item := range filterableItems { - ms := item.(*ModelItem) - names[i] = filterValue(ms) - } - - matches := fuzzy.Find(query, names) - sort.SliceStable(matches, func(i, j int) bool { - return matches[i].Score > matches[j].Score - }) - items := []list.Item{} visitedGroups := map[int]bool{} @@ -213,7 +198,19 @@ func (f *ModelsList) VisibleItems() []list.Item { // Find which group this item belongs to for gi, g := range f.groups { addedCount := 0 - name := g.Title + " " + name := strings.ToLower(g.Title) + " " + + names := make([]string, len(filterableItems)) + for i, item := range filterableItems { + ms := item.(*ModelItem) + names[i] = fmt.Sprintf("%s%s", name, ms.Filter()) + } + + matches := fuzzy.Find(query, names) + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].Score > matches[j].Score + }) + for _, match := range matches { item := filterableItems[match.Index].(*ModelItem) idxs := []int{} From 67efe867a128a9cea1bcde85b51864445dd8be39 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 6 Jan 2026 13:47:24 +0100 Subject: [PATCH 109/335] refactor: add more tools - diagnostics - sourcegraph - fetch - download --- internal/ui/chat/bash.go | 6 +-- internal/ui/chat/diagnostics.go | 68 ++++++++++++++++++++++++++ internal/ui/chat/fetch.go | 84 +++++++++++++++++++++++++++++++++ internal/ui/chat/file.go | 78 ++++++++++++++++++++++++++---- internal/ui/chat/search.go | 74 ++++++++++++++++++++++++++--- internal/ui/chat/tools.go | 48 +++++++++++++++++-- 6 files changed, 337 insertions(+), 21 deletions(-) create mode 100644 internal/ui/chat/diagnostics.go create mode 100644 internal/ui/chat/fetch.go diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index 000d83b8b1f7d1b06b113f3b92e1c6fc0ec4e32e..85b3c0db81756a47ec5a8009b24d975dcf4d3358 100644 --- a/internal/ui/chat/bash.go +++ b/internal/ui/chat/bash.go @@ -69,8 +69,8 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "background", "true") } - header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Nested, toolParams...) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Simple, toolParams...) + if opts.Simple { return header } @@ -201,7 +201,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt // header → nested check → early state → body. func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string { header := jobHeader(sty, opts.Status(), action, shellID, description, width) - if opts.Nested { + if opts.Simple { return header } diff --git a/internal/ui/chat/diagnostics.go b/internal/ui/chat/diagnostics.go new file mode 100644 index 0000000000000000000000000000000000000000..6d59141c651a4235cee3a65e97f456cd95c7dc77 --- /dev/null +++ b/internal/ui/chat/diagnostics.go @@ -0,0 +1,68 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// Diagnostics Tool +// ----------------------------------------------------------------------------- + +// DiagnosticsToolMessageItem is a message item that represents a diagnostics tool call. +type DiagnosticsToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*DiagnosticsToolMessageItem)(nil) + +// NewDiagnosticsToolMessageItem creates a new [DiagnosticsToolMessageItem]. +func NewDiagnosticsToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &DiagnosticsToolRenderContext{}, canceled) +} + +// DiagnosticsToolRenderContext renders diagnostics tool messages. +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.ToolCall.Finished && !opts.Canceled { + return pendingTool(sty, "Diagnostics", opts.Anim) + } + + var params tools.DiagnosticsParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + // Show "project" if no file path, otherwise show the file path. + mainParam := "project" + if params.FilePath != "" { + mainParam = fsext.PrettyPath(params.FilePath) + } + + header := toolHeader(sty, opts.Status(), "Diagnostics", cappedWidth, opts.Simple, mainParam) + if opts.Simple { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil || opts.Result.Content == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/fetch.go b/internal/ui/chat/fetch.go new file mode 100644 index 0000000000000000000000000000000000000000..53f5c066060266eb9f2f88844eb1640daf05e245 --- /dev/null +++ b/internal/ui/chat/fetch.go @@ -0,0 +1,84 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// Fetch Tool +// ----------------------------------------------------------------------------- + +// FetchToolMessageItem is a message item that represents a fetch tool call. +type FetchToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*FetchToolMessageItem)(nil) + +// NewFetchToolMessageItem creates a new [FetchToolMessageItem]. +func NewFetchToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &FetchToolRenderContext{}, canceled) +} + +// FetchToolRenderContext renders fetch tool messages. +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.ToolCall.Finished && !opts.Canceled { + 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) + } + + toolParams := []string{params.URL} + if params.Format != "" { + toolParams = append(toolParams, "format", params.Format) + } + if params.Timeout != 0 { + toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout)) + } + + header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Simple, toolParams...) + if opts.Simple { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil || opts.Result.Content == "" { + return header + } + + // Determine file extension for syntax highlighting based on format. + file := getFileExtensionForFormat(params.Format) + body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.Expanded) + return joinToolParts(header, body) +} + +// getFileExtensionForFormat returns a filename with appropriate extension for syntax highlighting. +func getFileExtensionForFormat(format string) string { + switch format { + case "text": + return "fetch.txt" + case "html": + return "fetch.html" + default: + return "fetch.md" + } +} diff --git a/internal/ui/chat/file.go b/internal/ui/chat/file.go index 255b0df3bac9455cd81f7c9ae882e4a2ae1d368a..2c0a1e49440ed263735afbd0ab3104034a2f4634 100644 --- a/internal/ui/chat/file.go +++ b/internal/ui/chat/file.go @@ -56,8 +56,8 @@ 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.Nested, toolParams...) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Simple, toolParams...) + if opts.Simple { return header } @@ -128,8 +128,8 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts } file := fsext.PrettyPath(params.FilePath) - header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Nested, file) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Simple, file) + if opts.Simple { return header } @@ -183,8 +183,8 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * } file := fsext.PrettyPath(params.FilePath) - header := toolHeader(sty, opts.Status(), "Edit", width, opts.Nested, file) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "Edit", width, opts.Simple, file) + if opts.Simple { return header } @@ -251,8 +251,8 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits))) } - header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Nested, toolParams...) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Simple, toolParams...) + if opts.Simple { return header } @@ -276,3 +276,65 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.Expanded) return joinToolParts(header, body) } + +// ----------------------------------------------------------------------------- +// Download Tool +// ----------------------------------------------------------------------------- + +// DownloadToolMessageItem is a message item that represents a download tool call. +type DownloadToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*DownloadToolMessageItem)(nil) + +// NewDownloadToolMessageItem creates a new [DownloadToolMessageItem]. +func NewDownloadToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &DownloadToolRenderContext{}, canceled) +} + +// DownloadToolRenderContext renders download tool messages. +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.ToolCall.Finished && !opts.Canceled { + 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) + } + + toolParams := []string{params.URL} + if params.FilePath != "" { + toolParams = append(toolParams, "file_path", fsext.PrettyPath(params.FilePath)) + } + if params.Timeout != 0 { + toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout)) + } + + header := toolHeader(sty, opts.Status(), "Download", cappedWidth, opts.Simple, toolParams...) + if opts.Simple { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil || opts.Result.Content == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/search.go b/internal/ui/chat/search.go index 6fa907d2c1f37dc17b62a925d720a52048f1f342..cd19eeef5712761a5c533686297f7baec65b87de 100644 --- a/internal/ui/chat/search.go +++ b/internal/ui/chat/search.go @@ -50,8 +50,8 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "path", params.Path) } - header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Nested, toolParams...) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Simple, toolParams...) + if opts.Simple { return header } @@ -115,8 +115,8 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "literal", "true") } - header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Nested, toolParams...) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Simple, toolParams...) + if opts.Simple { return header } @@ -175,8 +175,70 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To } path = fsext.PrettyPath(path) - header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Nested, path) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Simple, path) + if opts.Simple { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil || opts.Result.Content == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Sourcegraph Tool +// ----------------------------------------------------------------------------- + +// SourcegraphToolMessageItem is a message item that represents a sourcegraph tool call. +type SourcegraphToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*SourcegraphToolMessageItem)(nil) + +// NewSourcegraphToolMessageItem creates a new [SourcegraphToolMessageItem]. +func NewSourcegraphToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &SourcegraphToolRenderContext{}, canceled) +} + +// SourcegraphToolRenderContext renders sourcegraph tool messages. +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.ToolCall.Finished && !opts.Canceled { + 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) + } + + toolParams := []string{params.Query} + if params.Count != 0 { + toolParams = append(toolParams, "count", formatNonZero(params.Count)) + } + if params.ContextWindow != 0 { + toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow)) + } + + header := toolHeader(sty, opts.Status(), "Sourcegraph", cappedWidth, opts.Simple, toolParams...) + if opts.Simple { return header } diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index fb3ab943e3d5798278ded7f377657d4dd4e420e4..d621cad4c8a23f2cb638c2b708509feb4c1b64c7 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -40,6 +40,12 @@ type ToolMessageItem interface { SetResult(res *message.ToolResult) } +// Simplifiable is an interface for tool items that can render in a simplified mode. +// When simple mode is enabled, tools render as a compact single-line header. +type Simplifiable interface { + SetSimple(simple bool) +} + // DefaultToolRenderContext implements the default [ToolRenderer] interface. type DefaultToolRenderContext struct{} @@ -55,7 +61,7 @@ type ToolRenderOpts struct { Canceled bool Anim *anim.Anim Expanded bool - Nested bool + Simple bool IsSpinning bool PermissionRequested bool PermissionGranted bool @@ -106,6 +112,8 @@ type baseToolMessageItem struct { // we use this so we can efficiently cache // tools that have a capped width (e.x bash.. and others) hasCappedWidth bool + // isSimple indicates this tool should render in simplified/compact mode. + isSimple bool sty *styles.Styles anim *anim.Anim @@ -177,6 +185,14 @@ func NewToolMessageItem( return NewGrepToolMessageItem(sty, toolCall, result, canceled) case tools.LSToolName: return NewLSToolMessageItem(sty, toolCall, result, canceled) + case tools.DownloadToolName: + return NewDownloadToolMessageItem(sty, toolCall, result, canceled) + case tools.FetchToolName: + return NewFetchToolMessageItem(sty, toolCall, result, canceled) + case tools.SourcegraphToolName: + return NewSourcegraphToolMessageItem(sty, toolCall, result, canceled) + case tools.DiagnosticsToolName: + return NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled) default: // TODO: Implement other tool items return newBaseToolMessageItem( @@ -189,6 +205,12 @@ func NewToolMessageItem( } } +// SetSimple implements the Simplifiable interface. +func (t *baseToolMessageItem) SetSimple(simple bool) { + t.isSimple = simple + t.clearCache() +} + // ID returns the unique identifier for this tool message item. func (t *baseToolMessageItem) ID() string { return t.toolCall.ID @@ -230,6 +252,7 @@ func (t *baseToolMessageItem) Render(width int) string { Canceled: t.canceled, Anim: t.anim, Expanded: t.expanded, + Simple: t.isSimple, PermissionRequested: t.permissionRequested, PermissionGranted: t.permissionGranted, IsSpinning: t.isSpinning(), @@ -487,10 +510,10 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid // Add truncation message if needed. if len(lines) > maxLines && !expanded { - truncMsg := sty.Tool.ContentCodeTruncation. + out = append(out, sty.Tool.ContentCodeTruncation. Width(bodyWidth). - Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)) - out = append([]string{truncMsg}, out...) + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)), + ) } return sty.Tool.Body.Render(strings.Join(out, "\n")) @@ -574,6 +597,23 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri return sty.Tool.Body.Render(formatted) } +// formatTimeout converts timeout seconds to a duration string (e.g., "30s"). +// Returns empty string if timeout is 0. +func formatTimeout(timeout int) string { + if timeout == 0 { + return "" + } + return fmt.Sprintf("%ds", timeout) +} + +// 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) +} + // toolOutputMultiEditDiffContent renders a diff with optional failed edits note. func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string { bodyWidth := width - toolBodyLeftPaddingTotal From c0e3f79756a0f405da72cb0c78ce93e773bee073 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 6 Jan 2026 15:27:41 +0100 Subject: [PATCH 110/335] refactor: add agent tools and rename simple->compact --- internal/ui/chat/agent.go | 310 ++++++++++++++++++++++++++++++++ internal/ui/chat/bash.go | 10 +- internal/ui/chat/diagnostics.go | 6 +- internal/ui/chat/fetch.go | 114 +++++++++++- internal/ui/chat/file.go | 34 ++-- internal/ui/chat/messages.go | 1 + internal/ui/chat/search.go | 24 +-- internal/ui/chat/todos.go | 192 ++++++++++++++++++++ internal/ui/chat/tools.go | 185 +++++++++++++++---- internal/ui/model/chat.go | 36 ++++ internal/ui/model/ui.go | 160 ++++++++++++++++- internal/ui/styles/styles.go | 15 ++ 12 files changed, 1011 insertions(+), 76 deletions(-) create mode 100644 internal/ui/chat/agent.go create mode 100644 internal/ui/chat/todos.go diff --git a/internal/ui/chat/agent.go b/internal/ui/chat/agent.go new file mode 100644 index 0000000000000000000000000000000000000000..75c936a92ddfd1c75e9b2e49ec9ef7ee46f08ddf --- /dev/null +++ b/internal/ui/chat/agent.go @@ -0,0 +1,310 @@ +package chat + +import ( + "encoding/json" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/tree" + "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// Agent Tool +// ----------------------------------------------------------------------------- + +// NestedToolContainer is an interface for tool items that can contain nested tool calls. +type NestedToolContainer interface { + NestedTools() []ToolMessageItem + SetNestedTools(tools []ToolMessageItem) + AddNestedTool(tool ToolMessageItem) +} + +// AgentToolMessageItem is a message item that represents an agent tool call. +type AgentToolMessageItem struct { + *baseToolMessageItem + + nestedTools []ToolMessageItem +} + +var ( + _ ToolMessageItem = (*AgentToolMessageItem)(nil) + _ NestedToolContainer = (*AgentToolMessageItem)(nil) +) + +// NewAgentToolMessageItem creates a new [AgentToolMessageItem]. +func NewAgentToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) *AgentToolMessageItem { + t := &AgentToolMessageItem{} + t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled) + // For the agent tool we keep spinning until the tool call is finished. + t.isSpinningFn = func(state IsSpinningState) bool { + return state.Result == nil && !state.Canceled + } + return t +} + +// Animate progresses the message animation if it should be spinning. +func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { + if a.result != nil || a.canceled { + return nil + } + if msg.ID == a.ID() { + return a.anim.Animate(msg) + } + for _, nestedTool := range a.nestedTools { + if msg.ID != nestedTool.ID() { + continue + } + if s, ok := nestedTool.(Animatable); ok { + return s.Animate(msg) + } + } + return nil +} + +// NestedTools returns the nested tools. +func (a *AgentToolMessageItem) NestedTools() []ToolMessageItem { + return a.nestedTools +} + +// SetNestedTools sets the nested tools. +func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) { + a.nestedTools = tools + a.clearCache() +} + +// AddNestedTool adds a nested tool. +func (a *AgentToolMessageItem) AddNestedTool(tool ToolMessageItem) { + // Mark nested tools as simple (compact) rendering. + if s, ok := tool.(Compactable); ok { + s.SetCompact(true) + } + a.nestedTools = append(a.nestedTools, tool) + a.clearCache() +} + +// AgentToolRenderContext renders agent tool messages. +type AgentToolRenderContext struct { + agent *AgentToolMessageItem +} + +// 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.Canceled && len(r.agent.nestedTools) == 0 { + return pendingTool(sty, "Agent", opts.Anim) + } + + var params agent.AgentParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + prompt := params.Prompt + prompt = strings.ReplaceAll(prompt, "\n", " ") + + header := toolHeader(sty, opts.Status(), "Agent", cappedWidth, opts.Compact) + if opts.Compact { + return header + } + + // Build the task tag and prompt. + taskTag := sty.Tool.AgentTaskTag.Render("Task") + taskTagWidth := lipgloss.Width(taskTag) + + // Calculate remaining width for prompt. + remainingWidth := min(cappedWidth-taskTagWidth-3, 120-taskTagWidth-3) // -3 for spacing + + promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) + + header = lipgloss.JoinVertical( + lipgloss.Left, + header, + "", + lipgloss.JoinHorizontal( + lipgloss.Left, + taskTag, + " ", + promptText, + ), + ) + + // Build tree with nested tool calls. + childTools := tree.Root(header) + + for _, nestedTool := range r.agent.nestedTools { + childView := nestedTool.Render(remainingWidth) + childTools.Child(childView) + } + + // Build parts. + var parts []string + parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String()) + + // Show animation if still running. + if opts.Result == nil && !opts.Canceled { + parts = append(parts, "", opts.Anim.Render()) + } + + result := lipgloss.JoinVertical(lipgloss.Left, parts...) + + // Add body content when completed. + if opts.Result != nil && opts.Result.Content != "" { + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent) + return joinToolParts(result, body) + } + + return result +} + +// ----------------------------------------------------------------------------- +// Agentic Fetch Tool +// ----------------------------------------------------------------------------- + +// AgenticFetchToolMessageItem is a message item that represents an agentic fetch tool call. +type AgenticFetchToolMessageItem struct { + *baseToolMessageItem + + nestedTools []ToolMessageItem +} + +var ( + _ ToolMessageItem = (*AgenticFetchToolMessageItem)(nil) + _ NestedToolContainer = (*AgenticFetchToolMessageItem)(nil) +) + +// NewAgenticFetchToolMessageItem creates a new [AgenticFetchToolMessageItem]. +func NewAgenticFetchToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) *AgenticFetchToolMessageItem { + t := &AgenticFetchToolMessageItem{} + t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled) + // For the agentic fetch tool we keep spinning until the tool call is finished. + t.isSpinningFn = func(state IsSpinningState) bool { + return state.Result == nil && !state.Canceled + } + return t +} + +// NestedTools returns the nested tools. +func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem { + return a.nestedTools +} + +// SetNestedTools sets the nested tools. +func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) { + a.nestedTools = tools + a.clearCache() +} + +// AddNestedTool adds a nested tool. +func (a *AgenticFetchToolMessageItem) AddNestedTool(tool ToolMessageItem) { + // Mark nested tools as simple (compact) rendering. + if s, ok := tool.(Compactable); ok { + s.SetCompact(true) + } + a.nestedTools = append(a.nestedTools, tool) + a.clearCache() +} + +// AgenticFetchToolRenderContext renders agentic fetch tool messages. +type AgenticFetchToolRenderContext struct { + fetch *AgenticFetchToolMessageItem +} + +// agenticFetchParams matches tools.AgenticFetchParams. +type agenticFetchParams struct { + URL string `json:"url,omitempty"` + Prompt string `json:"prompt"` +} + +// 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.Canceled && len(r.fetch.nestedTools) == 0 { + return pendingTool(sty, "Agentic Fetch", opts.Anim) + } + + var params agenticFetchParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + prompt := params.Prompt + prompt = strings.ReplaceAll(prompt, "\n", " ") + + // Build header with optional URL param. + toolParams := []string{} + if params.URL != "" { + toolParams = append(toolParams, params.URL) + } + + header := toolHeader(sty, opts.Status(), "Agentic Fetch", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + // Build the prompt tag. + promptTag := sty.Base.Bold(true). + Padding(0, 1). + MarginLeft(2). + Background(sty.Green). + Foreground(sty.Border). + Render("Prompt") + promptTagWidth := lipgloss.Width(promptTag) + + // Calculate remaining width for prompt text. + remainingWidth := cappedWidth - promptTagWidth - 3 // -3 for spacing + if remainingWidth > 120-promptTagWidth-3 { + remainingWidth = 120 - promptTagWidth - 3 + } + + promptText := sty.Base.Width(remainingWidth).Render(prompt) + + header = lipgloss.JoinVertical( + lipgloss.Left, + header, + "", + lipgloss.JoinHorizontal( + lipgloss.Left, + promptTag, + " ", + promptText, + ), + ) + + // Build tree with nested tool calls. + childTools := tree.Root(header) + + for _, nestedTool := range r.fetch.nestedTools { + childView := nestedTool.Render(remainingWidth) + childTools.Child(childView) + } + + // Build parts. + var parts []string + parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String()) + + // Show animation if still running. + if opts.Result == nil && !opts.Canceled { + parts = append(parts, "", opts.Anim.Render()) + } + + result := lipgloss.JoinVertical(lipgloss.Left, parts...) + + // Add body content when completed. + if opts.Result != nil && opts.Result.Content != "" { + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent) + return joinToolParts(result, body) + } + + return result +} diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index 85b3c0db81756a47ec5a8009b24d975dcf4d3358..0202780cf1e670b48cb7f9a8b9d27a0fe44f5405 100644 --- a/internal/ui/chat/bash.go +++ b/internal/ui/chat/bash.go @@ -69,8 +69,8 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "background", "true") } - header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Simple, toolParams...) - if opts.Simple { + header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { return header } @@ -91,7 +91,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * } bodyWidth := cappedWidth - toolBodyLeftPaddingTotal - body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded)) + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } @@ -201,7 +201,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt // header → nested check → early state → body. func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string { header := jobHeader(sty, opts.Status(), action, shellID, description, width) - if opts.Simple { + if opts.Compact { return header } @@ -214,7 +214,7 @@ func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, } bodyWidth := width - toolBodyLeftPaddingTotal - body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.Expanded)) + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } diff --git a/internal/ui/chat/diagnostics.go b/internal/ui/chat/diagnostics.go index 6d59141c651a4235cee3a65e97f456cd95c7dc77..8ca5436b9082033a9cbb0debedffec041833ea11 100644 --- a/internal/ui/chat/diagnostics.go +++ b/internal/ui/chat/diagnostics.go @@ -49,8 +49,8 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, mainParam = fsext.PrettyPath(params.FilePath) } - header := toolHeader(sty, opts.Status(), "Diagnostics", cappedWidth, opts.Simple, mainParam) - if opts.Simple { + header := toolHeader(sty, opts.Status(), "Diagnostics", cappedWidth, opts.Compact, mainParam) + if opts.Compact { return header } @@ -63,6 +63,6 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, } bodyWidth := cappedWidth - toolBodyLeftPaddingTotal - body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + 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 53f5c066060266eb9f2f88844eb1640daf05e245..41e35c90004a76337e8ce3d59908cadf32ed699f 100644 --- a/internal/ui/chat/fetch.go +++ b/internal/ui/chat/fetch.go @@ -52,8 +52,8 @@ 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.Simple, toolParams...) - if opts.Simple { + header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { return header } @@ -67,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, cappedWidth, opts.Expanded) + body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.ExpandedContent) return joinToolParts(header, body) } @@ -82,3 +82,111 @@ func getFileExtensionForFormat(format string) string { return "fetch.md" } } + +// ----------------------------------------------------------------------------- +// WebFetch Tool +// ----------------------------------------------------------------------------- + +// WebFetchToolMessageItem is a message item that represents a web_fetch tool call. +type WebFetchToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*WebFetchToolMessageItem)(nil) + +// NewWebFetchToolMessageItem creates a new [WebFetchToolMessageItem]. +func NewWebFetchToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &WebFetchToolRenderContext{}, canceled) +} + +// WebFetchToolRenderContext renders web_fetch tool messages. +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.ToolCall.Finished && !opts.Canceled { + 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) + } + + toolParams := []string{params.URL} + header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil || opts.Result.Content == "" { + return header + } + + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// WebSearch Tool +// ----------------------------------------------------------------------------- + +// WebSearchToolMessageItem is a message item that represents a web_search tool call. +type WebSearchToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*WebSearchToolMessageItem)(nil) + +// NewWebSearchToolMessageItem creates a new [WebSearchToolMessageItem]. +func NewWebSearchToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &WebSearchToolRenderContext{}, canceled) +} + +// WebSearchToolRenderContext renders web_search tool messages. +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.ToolCall.Finished && !opts.Canceled { + 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) + } + + toolParams := []string{params.Query} + header := toolHeader(sty, opts.Status(), "Search", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil || opts.Result.Content == "" { + return header + } + + 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 2c0a1e49440ed263735afbd0ab3104034a2f4634..ca0e0b4934e806bbed0c7826161bb2c91a10843f 100644 --- a/internal/ui/chat/file.go +++ b/internal/ui/chat/file.go @@ -56,8 +56,8 @@ 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.Simple, toolParams...) - if opts.Simple { + header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { return header } @@ -87,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, cappedWidth, opts.Expanded) + body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, cappedWidth, opts.ExpandedContent) return joinToolParts(header, body) } @@ -128,8 +128,8 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts } file := fsext.PrettyPath(params.FilePath) - header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Simple, file) - if opts.Simple { + header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Compact, file) + if opts.Compact { return header } @@ -142,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, cappedWidth, opts.Expanded) + body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent) return joinToolParts(header, body) } @@ -183,8 +183,8 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * } file := fsext.PrettyPath(params.FilePath) - header := toolHeader(sty, opts.Status(), "Edit", width, opts.Simple, file) - if opts.Simple { + header := toolHeader(sty, opts.Status(), "Edit", width, opts.Compact, file) + if opts.Compact { return header } @@ -200,12 +200,12 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * var meta tools.EditResponseMetadata if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil { bodyWidth := width - toolBodyLeftPaddingTotal - body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } // Render diff. - body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.Expanded) + body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent) return joinToolParts(header, body) } @@ -251,8 +251,8 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits))) } - header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Simple, toolParams...) - if opts.Simple { + header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Compact, toolParams...) + if opts.Compact { return header } @@ -268,12 +268,12 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o var meta tools.MultiEditResponseMetadata if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil { bodyWidth := width - toolBodyLeftPaddingTotal - body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } // Render diff with optional failed edits note. - body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.Expanded) + body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent) return joinToolParts(header, body) } @@ -321,8 +321,8 @@ 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.Simple, toolParams...) - if opts.Simple { + header := toolHeader(sty, opts.Status(), "Download", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { return header } @@ -335,6 +335,6 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op } bodyWidth := cappedWidth - toolBodyLeftPaddingTotal - body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index f75a9f9328ff01809208bc30a58b3531e766033d..da55faa10c842f7ad66ad824f69564694855bb50 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -171,6 +171,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m } items = append(items, NewToolMessageItem( sty, + msg.ID, tc, result, msg.FinishReason() == message.FinishReasonCanceled, diff --git a/internal/ui/chat/search.go b/internal/ui/chat/search.go index cd19eeef5712761a5c533686297f7baec65b87de..3430d7d5c8aebe6e93979284f659e74b60316ca9 100644 --- a/internal/ui/chat/search.go +++ b/internal/ui/chat/search.go @@ -50,8 +50,8 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "path", params.Path) } - header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Simple, toolParams...) - if opts.Simple { + header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { return header } @@ -64,7 +64,7 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * } bodyWidth := cappedWidth - toolBodyLeftPaddingTotal - body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } @@ -115,8 +115,8 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "literal", "true") } - header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Simple, toolParams...) - if opts.Simple { + header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { return header } @@ -129,7 +129,7 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * } bodyWidth := cappedWidth - toolBodyLeftPaddingTotal - body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } @@ -175,8 +175,8 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To } path = fsext.PrettyPath(path) - header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Simple, path) - if opts.Simple { + header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Compact, path) + if opts.Compact { return header } @@ -189,7 +189,7 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To } bodyWidth := cappedWidth - toolBodyLeftPaddingTotal - body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) return joinToolParts(header, body) } @@ -237,8 +237,8 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow)) } - header := toolHeader(sty, opts.Status(), "Sourcegraph", cappedWidth, opts.Simple, toolParams...) - if opts.Simple { + header := toolHeader(sty, opts.Status(), "Sourcegraph", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { return header } @@ -251,6 +251,6 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, } bodyWidth := cappedWidth - toolBodyLeftPaddingTotal - body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + 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 new file mode 100644 index 0000000000000000000000000000000000000000..3f92de9b32287270298b8a20c463850a32d110b5 --- /dev/null +++ b/internal/ui/chat/todos.go @@ -0,0 +1,192 @@ +package chat + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// ----------------------------------------------------------------------------- +// Todos Tool +// ----------------------------------------------------------------------------- + +// TodosToolMessageItem is a message item that represents a todos tool call. +type TodosToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*TodosToolMessageItem)(nil) + +// NewTodosToolMessageItem creates a new [TodosToolMessageItem]. +func NewTodosToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &TodosToolRenderContext{}, canceled) +} + +// TodosToolRenderContext renders todos tool messages. +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.ToolCall.Finished && !opts.Canceled { + return pendingTool(sty, "To-Do", opts.Anim) + } + + 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 := json.Unmarshal([]byte(opts.ToolCall.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 := sty.Tool.TodoRatio.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 opts.Result != nil && opts.Result.Metadata != "" { + if err := json.Unmarshal([]byte(opts.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 = formatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth) + } else { + // Build header based on what changed. + hasCompleted := len(meta.JustCompleted) > 0 + hasStarted := meta.JustStarted != "" + allCompleted := meta.Completed == meta.Total + + ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total)) + if hasCompleted && hasStarted { + text := sty.Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted))) + headerText = fmt.Sprintf("%s%s", ratio, text) + } else if hasCompleted { + text := sty.Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted))) + if allCompleted { + text = sty.Subtle.Render(" · completed all") + } + headerText = fmt.Sprintf("%s%s", ratio, text) + } else if hasStarted { + headerText = fmt.Sprintf("%s%s", ratio, sty.Subtle.Render(" · starting task")) + } else { + headerText = ratio + } + + // Build body with details. + if allCompleted { + // Show all todos when all are completed, like when created. + body = formatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth) + } else if meta.JustStarted != "" { + body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") + + sty.Base.Render(meta.JustStarted) + } + } + } + } + } + + toolParams := []string{headerText} + header := toolHeader(sty, opts.Status(), "To-Do", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if body == "" { + return header + } + + return joinToolParts(header, sty.Tool.Body.Render(body)) +} + +// formatTodosList formats a list of todos for display. +func formatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, 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 + textStyle := sty.Base + + switch todo.Status { + case session.TodoStatusCompleted: + prefix = sty.Tool.TodoCompletedIcon.Render(styles.TodoCompletedIcon) + " " + case session.TodoStatusInProgress: + prefix = sty.Tool.TodoInProgressIcon.Render(inProgressIcon + " ") + default: + prefix = sty.Tool.TodoPendingIcon.Render(styles.TodoPendingIcon) + " " + } + + 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") +} + +// sortTodos sorts todos by status: completed, in_progress, pending. +func sortTodos(todos []session.Todo) { + slices.SortStableFunc(todos, func(a, b session.Todo) int { + return statusOrder(a.Status) - statusOrder(b.Status) + }) +} + +// statusOrder returns the sort order for a todo status. +func statusOrder(s session.TodoStatus) int { + switch s { + case session.TodoStatusCompleted: + return 0 + case session.TodoStatusInProgress: + return 1 + default: + return 2 + } +} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index d621cad4c8a23f2cb638c2b708509feb4c1b64c7..d336e6405ba2129b4c173bdf5a90154ae6c9c23f 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -6,6 +6,8 @@ import ( tea "charm.land/bubbletea/v2" "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/message" "github.com/charmbracelet/crush/internal/ui/anim" @@ -38,14 +40,27 @@ type ToolMessageItem interface { ToolCall() message.ToolCall SetToolCall(tc message.ToolCall) SetResult(res *message.ToolResult) + MessageID() string + SetMessageID(id string) } -// Simplifiable is an interface for tool items that can render in a simplified mode. -// When simple mode is enabled, tools render as a compact single-line header. -type Simplifiable interface { - SetSimple(simple bool) +// Compactable is an interface for tool items that can render in a compacted mode. +// When compact mode is enabled, tools render as a compact single-line header. +type Compactable interface { + SetCompact(compact bool) } +// IsSpinningState contains the state passed to IsSpinningFn for custom spinning logic. +type IsSpinningState struct { + ToolCall message.ToolCall + Result *message.ToolResult + Canceled bool +} + +// IsSpinningFn is a function type for custom spinning logic. +// Returns true if the tool should show the spinning animation. +type IsSpinningFn func(state IsSpinningState) bool + // DefaultToolRenderContext implements the default [ToolRenderer] interface. type DefaultToolRenderContext struct{} @@ -60,8 +75,8 @@ type ToolRenderOpts struct { Result *message.ToolResult Canceled bool Anim *anim.Anim - Expanded bool - Simple bool + ExpandedContent bool + Compact bool IsSpinning bool PermissionRequested bool PermissionGranted bool @@ -106,18 +121,22 @@ type baseToolMessageItem struct { toolRenderer ToolRenderer toolCall message.ToolCall result *message.ToolResult + messageID string canceled bool permissionRequested bool permissionGranted bool // we use this so we can efficiently cache // tools that have a capped width (e.x bash.. and others) hasCappedWidth bool - // isSimple indicates this tool should render in simplified/compact mode. - isSimple bool + // isCompact indicates this tool should render in compact mode. + isCompact bool + // isSpinningFn allows tools to override the default spinning logic. + // If nil, uses the default: !toolCall.Finished && !canceled. + isSpinningFn IsSpinningFn - sty *styles.Styles - anim *anim.Anim - expanded bool + sty *styles.Styles + anim *anim.Anim + expandedContent bool } // newBaseToolMessageItem is the internal constructor for base tool message items. @@ -157,45 +176,58 @@ func newBaseToolMessageItem( // NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name. // // It returns a specific tool message item type if implemented, otherwise it -// returns a generic tool message item. +// returns a generic tool message item. The messageID is the ID of the assistant +// message containing this tool call. func NewToolMessageItem( sty *styles.Styles, + messageID string, toolCall message.ToolCall, result *message.ToolResult, canceled bool, ) ToolMessageItem { + var item ToolMessageItem switch toolCall.Name { case tools.BashToolName: - return NewBashToolMessageItem(sty, toolCall, result, canceled) + item = NewBashToolMessageItem(sty, toolCall, result, canceled) case tools.JobOutputToolName: - return NewJobOutputToolMessageItem(sty, toolCall, result, canceled) + item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled) case tools.JobKillToolName: - return NewJobKillToolMessageItem(sty, toolCall, result, canceled) + item = NewJobKillToolMessageItem(sty, toolCall, result, canceled) case tools.ViewToolName: - return NewViewToolMessageItem(sty, toolCall, result, canceled) + item = NewViewToolMessageItem(sty, toolCall, result, canceled) case tools.WriteToolName: - return NewWriteToolMessageItem(sty, toolCall, result, canceled) + item = NewWriteToolMessageItem(sty, toolCall, result, canceled) case tools.EditToolName: - return NewEditToolMessageItem(sty, toolCall, result, canceled) + item = NewEditToolMessageItem(sty, toolCall, result, canceled) case tools.MultiEditToolName: - return NewMultiEditToolMessageItem(sty, toolCall, result, canceled) + item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled) case tools.GlobToolName: - return NewGlobToolMessageItem(sty, toolCall, result, canceled) + item = NewGlobToolMessageItem(sty, toolCall, result, canceled) case tools.GrepToolName: - return NewGrepToolMessageItem(sty, toolCall, result, canceled) + item = NewGrepToolMessageItem(sty, toolCall, result, canceled) case tools.LSToolName: - return NewLSToolMessageItem(sty, toolCall, result, canceled) + item = NewLSToolMessageItem(sty, toolCall, result, canceled) case tools.DownloadToolName: - return NewDownloadToolMessageItem(sty, toolCall, result, canceled) + item = NewDownloadToolMessageItem(sty, toolCall, result, canceled) case tools.FetchToolName: - return NewFetchToolMessageItem(sty, toolCall, result, canceled) + item = NewFetchToolMessageItem(sty, toolCall, result, canceled) case tools.SourcegraphToolName: - return NewSourcegraphToolMessageItem(sty, toolCall, result, canceled) + item = NewSourcegraphToolMessageItem(sty, toolCall, result, canceled) case tools.DiagnosticsToolName: - return NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled) + item = NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled) + case agent.AgentToolName: + item = NewAgentToolMessageItem(sty, toolCall, result, canceled) + case tools.AgenticFetchToolName: + item = NewAgenticFetchToolMessageItem(sty, toolCall, result, canceled) + case tools.WebFetchToolName: + item = NewWebFetchToolMessageItem(sty, toolCall, result, canceled) + case tools.WebSearchToolName: + item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled) + case tools.TodosToolName: + item = NewTodosToolMessageItem(sty, toolCall, result, canceled) default: // TODO: Implement other tool items - return newBaseToolMessageItem( + item = newBaseToolMessageItem( sty, toolCall, result, @@ -203,11 +235,13 @@ func NewToolMessageItem( canceled, ) } + item.SetMessageID(messageID) + return item } -// SetSimple implements the Simplifiable interface. -func (t *baseToolMessageItem) SetSimple(simple bool) { - t.isSimple = simple +// SetCompact implements the Compactable interface. +func (t *baseToolMessageItem) SetCompact(compact bool) { + t.isCompact = compact t.clearCache() } @@ -243,6 +277,10 @@ func (t *baseToolMessageItem) Render(width int) string { style = t.sty.Chat.Message.ToolCallFocused } + if t.isCompact { + style = t.sty.Chat.Message.ToolCallCompact + } + content, height, ok := t.getCachedRender(toolItemWidth) // if we are spinning or there is no cache rerender if !ok || t.isSpinning() { @@ -251,8 +289,8 @@ func (t *baseToolMessageItem) Render(width int) string { Result: t.result, Canceled: t.canceled, Anim: t.anim, - Expanded: t.expanded, - Simple: t.isSimple, + ExpandedContent: t.expandedContent, + Compact: t.isCompact, PermissionRequested: t.permissionRequested, PermissionGranted: t.permissionGranted, IsSpinning: t.isSpinning(), @@ -283,6 +321,16 @@ func (t *baseToolMessageItem) SetResult(res *message.ToolResult) { t.clearCache() } +// MessageID returns the ID of the message containing this tool call. +func (t *baseToolMessageItem) MessageID() string { + return t.messageID +} + +// SetMessageID sets the ID of the message containing this tool call. +func (t *baseToolMessageItem) SetMessageID(id string) { + t.messageID = id +} + // SetPermissionRequested sets whether permission has been requested for this tool call. // TODO: Consider merging with SetPermissionGranted and add an interface for // permission management. @@ -301,12 +349,24 @@ func (t *baseToolMessageItem) SetPermissionGranted(granted bool) { // isSpinning returns true if the tool should show animation. func (t *baseToolMessageItem) isSpinning() bool { + if t.isSpinningFn != nil { + return t.isSpinningFn(IsSpinningState{ + ToolCall: t.toolCall, + Result: t.result, + Canceled: t.canceled, + }) + } return !t.toolCall.Finished && !t.canceled } +// SetIsSpinningFn sets a custom function to determine if the tool should spin. +func (t *baseToolMessageItem) SetIsSpinningFn(fn IsSpinningFn) { + t.isSpinningFn = fn +} + // ToggleExpanded toggles the expanded state of the thinking box. func (t *baseToolMessageItem) ToggleExpanded() { - t.expanded = !t.expanded + t.expandedContent = !t.expandedContent t.clearCache() } @@ -654,3 +714,62 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools. return sty.Tool.Body.Render(formatted) } + +// roundedEnumerator creates a tree enumerator with rounded corners. +func roundedEnumerator(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 + } +} + +// toolOutputMarkdownContent renders markdown content with optional truncation. +func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\t", " ") + content = strings.TrimSpace(content) + + // Cap width for readability. + if width > 120 { + width = 120 + } + + renderer := common.PlainMarkdownRenderer(sty, width) + rendered, err := renderer.Render(content) + if err != nil { + return toolOutputPlainContent(sty, content, width, expanded) + } + + lines := strings.Split(rendered, "\n") + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) + } + + var out []string + for i, ln := range lines { + if i >= maxLines { + break + } + out = append(out, ln) + } + + if len(lines) > maxLines && !expanded { + out = append(out, sty.Tool.ContentTruncation. + Width(width). + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)), + ) + } + + return sty.Tool.Body.Render(strings.Join(out, "\n")) +} diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 9c11a2d512d8355d66ad71c72db1eda078ecf85c..76a82a7d7242b4b089381685763750ee762c043c 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -77,6 +77,12 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) { items := make([]list.Item, len(msgs)) for i, msg := range msgs { m.idInxMap[msg.ID()] = i + // Register nested tool IDs for tools that contain nested tools. + if container, ok := msg.(chat.NestedToolContainer); ok { + for _, nested := range container.NestedTools() { + m.idInxMap[nested.ID()] = i + } + } items[i] = msg } m.list.SetItems(items...) @@ -89,11 +95,41 @@ func (m *Chat) AppendMessages(msgs ...chat.MessageItem) { indexOffset := m.list.Len() for i, msg := range msgs { m.idInxMap[msg.ID()] = indexOffset + i + // Register nested tool IDs for tools that contain nested tools. + if container, ok := msg.(chat.NestedToolContainer); ok { + for _, nested := range container.NestedTools() { + m.idInxMap[nested.ID()] = indexOffset + i + } + } items[i] = msg } m.list.AppendItems(items...) } +// UpdateNestedToolIDs updates the ID map for nested tools within a container. +// Call this after modifying nested tools to ensure animations work correctly. +func (m *Chat) UpdateNestedToolIDs(containerID string) { + idx, ok := m.idInxMap[containerID] + if !ok { + return + } + + item, ok := m.list.ItemAt(idx).(chat.MessageItem) + if !ok { + return + } + + container, ok := item.(chat.NestedToolContainer) + if !ok { + return + } + + // Register all nested tool IDs to point to the container's index. + for _, nested := range container.NestedTools() { + m.idInxMap[nested.ID()] = idx + } +} + // Animate animates items in the chat list. Only propagates animation messages // to visible items to save CPU. When items are not visible, their animation ID // is tracked so it can be restarted when they become visible again. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 6072c4292c07aebbd92a1329944bc20b9826bb48..3d7499e519a24807b43677d2a5a0043b2e57d250 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -199,8 +199,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case pubsub.Event[message.Message]: - // TODO: handle nested messages for agentic tools - if m.session == nil || msg.Payload.SessionID != m.session.ID { + // Check if this is a child session message for an agent tool. + if m.session == nil { + break + } + if msg.Payload.SessionID != m.session.ID { + // This might be a child session message from an agent tool. + if cmd := m.handleChildSessionMessage(msg); cmd != nil { + cmds = append(cmds, cmd) + } break } switch msg.Type { @@ -374,6 +381,9 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...) } + // Load nested tool calls for agent/agentic_fetch tools. + m.loadNestedToolCalls(items) + // If the user switches between sessions while the agent is working we want // to make sure the animations are shown. for _, item := range items { @@ -392,6 +402,64 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { return tea.Batch(cmds...) } +// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools. +func (m *UI) loadNestedToolCalls(items []chat.MessageItem) { + for _, item := range items { + nestedContainer, ok := item.(chat.NestedToolContainer) + if !ok { + continue + } + toolItem, ok := item.(chat.ToolMessageItem) + if !ok { + continue + } + + tc := toolItem.ToolCall() + messageID := toolItem.MessageID() + + // Get the agent tool session ID. + agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID) + + // Fetch nested messages. + nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID) + if err != nil || len(nestedMsgs) == 0 { + continue + } + + // Build tool result map for nested messages. + nestedMsgPtrs := make([]*message.Message, len(nestedMsgs)) + for i := range nestedMsgs { + nestedMsgPtrs[i] = &nestedMsgs[i] + } + nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs) + + // Extract nested tool items. + var nestedTools []chat.ToolMessageItem + for _, nestedMsg := range nestedMsgPtrs { + nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap) + for _, nestedItem := range nestedItems { + if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok { + // Mark nested tools as simple (compact) rendering. + if simplifiable, ok := nestedToolItem.(chat.Compactable); ok { + simplifiable.SetCompact(true) + } + nestedTools = append(nestedTools, nestedToolItem) + } + } + } + + // Recursively load nested tool calls for any agent tools within. + nestedMessageItems := make([]chat.MessageItem, len(nestedTools)) + for i, nt := range nestedTools { + nestedMessageItems[i] = nt + } + m.loadNestedToolCalls(nestedMessageItems) + + // Set nested tools on the parent. + nestedContainer.SetNestedTools(nestedTools) + } +} + // appendSessionMessage appends a new message to the current session in the chat // if the message is a tool result it will update the corresponding tool call message func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { @@ -455,7 +523,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { } } if existingToolItem == nil { - items = append(items, chat.NewToolMessageItem(m.com.Styles, tc, nil, false)) + items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false)) } } @@ -474,6 +542,92 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { return tea.Batch(cmds...) } +// handleChildSessionMessage handles messages from child sessions (agent tools). +func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd { + var cmds []tea.Cmd + + // Only process messages with tool calls or results. + 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 + _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID) + if !ok { + return nil + } + + // Find the parent agent tool item. + var agentItem chat.NestedToolContainer + for i := 0; i < m.chat.Len(); i++ { + item := m.chat.MessageItem(toolCallID) + if item == nil { + continue + } + if agent, ok := item.(chat.NestedToolContainer); ok { + if toolMessageItem, ok := item.(chat.ToolMessageItem); ok { + if toolMessageItem.ToolCall().ID == toolCallID { + // Verify this agent belongs to the correct parent message. + // We can't directly check parentMessageID on the item, so we trust the session parsing. + agentItem = agent + break + } + } + } + } + + if agentItem == nil { + return nil + } + + // Get existing nested tools. + nestedTools := agentItem.NestedTools() + + // Update or create nested tool calls. + for _, tc := range event.Payload.ToolCalls() { + found := false + for _, existingTool := range nestedTools { + if existingTool.ToolCall().ID == tc.ID { + existingTool.SetToolCall(tc) + found = true + break + } + } + if !found { + // Create a new nested tool item. + nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false) + if simplifiable, ok := nestedItem.(chat.Compactable); ok { + simplifiable.SetCompact(true) + } + if animatable, ok := nestedItem.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + nestedTools = append(nestedTools, nestedItem) + } + } + + // Update nested tool results. + for _, tr := range event.Payload.ToolResults() { + for _, nestedTool := range nestedTools { + if nestedTool.ToolCall().ID == tr.ToolCallID { + nestedTool.SetResult(&tr) + break + } + } + } + + // Update the agent item with the new nested tools. + agentItem.SetNestedTools(nestedTools) + + // Update the chat so it updates the index map for animations to work as expected + m.chat.UpdateNestedToolIDs(toolCallID) + + return tea.Batch(cmds...) +} + func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { var cmds []tea.Cmd diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index a0c7ad418a0c8d4dbeb041cfd48d5a16fd110622..d7d8d6d8a38432b77b8ce49e7d04961a6e489cc8 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -210,6 +210,7 @@ type Styles struct { ErrorDetails lipgloss.Style Attachment lipgloss.Style ToolCallFocused lipgloss.Style + ToolCallCompact lipgloss.Style ToolCallBlurred lipgloss.Style SectionHeader lipgloss.Style @@ -277,6 +278,12 @@ type Styles struct { // Agent task styles AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold) AgentPrompt lipgloss.Style // Agent prompt text + + // Todo styles + TodoRatio lipgloss.Style // Todo ratio (e.g., "2/5") + TodoCompletedIcon lipgloss.Style // Completed todo icon + TodoInProgressIcon lipgloss.Style // In-progress todo icon + TodoPendingIcon lipgloss.Style // Pending todo icon } // Dialog styles @@ -1005,6 +1012,12 @@ func DefaultStyles() Styles { s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white) s.Tool.AgentPrompt = s.Muted + // Todo styles + s.Tool.TodoRatio = base.Foreground(blueDark) + s.Tool.TodoCompletedIcon = base.Foreground(green) + s.Tool.TodoInProgressIcon = base.Foreground(greenDark) + s.Tool.TodoPendingIcon = base.Foreground(fgMuted) + // Buttons s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary) s.ButtonBlur = s.Base.Background(bgSubtle) @@ -1082,6 +1095,8 @@ func DefaultStyles() Styles { BorderLeft(true). BorderForeground(greenDark) s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2) + // No padding or border for compact tool calls within messages + s.Chat.Message.ToolCallCompact = s.Muted s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2) // Thinking section styles From 614b0bc970f6b82584d0e9159e57a522e3077d4b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 6 Jan 2026 15:34:53 +0100 Subject: [PATCH 111/335] chore: some cleanup --- internal/ui/chat/agent.go | 16 ++++------------ internal/ui/chat/tools.go | 8 ++++---- internal/ui/dialog/models_list.go | 18 +++++++++--------- internal/ui/styles/styles.go | 6 ++++++ 4 files changed, 23 insertions(+), 25 deletions(-) diff --git a/internal/ui/chat/agent.go b/internal/ui/chat/agent.go index 75c936a92ddfd1c75e9b2e49ec9ef7ee46f08ddf..baa9633623be16fab85d5e0b7b190a4173f1be15 100644 --- a/internal/ui/chat/agent.go +++ b/internal/ui/chat/agent.go @@ -120,7 +120,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, 120-taskTagWidth-3) // -3 for spacing + remainingWidth := min(cappedWidth-taskTagWidth-3, maxTextWidth-taskTagWidth-3) // -3 for spacing promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) @@ -253,21 +253,13 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int } // Build the prompt tag. - promptTag := sty.Base.Bold(true). - Padding(0, 1). - MarginLeft(2). - Background(sty.Green). - Foreground(sty.Border). - Render("Prompt") + promptTag := sty.Tool.AgenticFetchPromptTag.Render("Prompt") promptTagWidth := lipgloss.Width(promptTag) // Calculate remaining width for prompt text. - remainingWidth := cappedWidth - promptTagWidth - 3 // -3 for spacing - if remainingWidth > 120-promptTagWidth-3 { - remainingWidth = 120 - promptTagWidth - 3 - } + remainingWidth := min(cappedWidth-promptTagWidth-3, maxTextWidth-promptTagWidth-3) // -3 for spacing - promptText := sty.Base.Width(remainingWidth).Render(prompt) + promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) header = lipgloss.JoinVertical( lipgloss.Left, diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index d336e6405ba2129b4c173bdf5a90154ae6c9c23f..abed4d12174f8e4b5b1c98d84fa92fbd7b7230c9 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -634,7 +634,7 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri Width(bodyWidth) // Use split view for wide terminals. - if width > 120 { + if width > maxTextWidth { formatter = formatter.Split() } @@ -684,7 +684,7 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools. Width(bodyWidth) // Use split view for wide terminals. - if width > 120 { + if width > maxTextWidth { formatter = formatter.Split() } @@ -740,8 +740,8 @@ func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, ex content = strings.TrimSpace(content) // Cap width for readability. - if width > 120 { - width = 120 + if width > maxTextWidth { + width = maxTextWidth } renderer := common.PlainMarkdownRenderer(sty, width) diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index 61541277e3b4e81aac9e839a6e3a52480347e31a..bbd4dafad3591db2e62624243fd9ae919bed5206 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -68,7 +68,7 @@ func (f *ModelsList) SetSelected(index int) { f.List.SetSelected(index) for { - selectedItem := f.List.SelectedItem() + selectedItem := f.SelectedItem() if _, ok := selectedItem.(*ModelItem); ok { return } @@ -104,7 +104,7 @@ func (f *ModelsList) SetSelectedItem(itemID string) { func (f *ModelsList) SelectNext() (v bool) { for { v = f.List.SelectNext() - selectedItem := f.List.SelectedItem() + selectedItem := f.SelectedItem() if _, ok := selectedItem.(*ModelItem); ok { return v } @@ -116,7 +116,7 @@ func (f *ModelsList) SelectNext() (v bool) { func (f *ModelsList) SelectPrev() (v bool) { for { v = f.List.SelectPrev() - selectedItem := f.List.SelectedItem() + selectedItem := f.SelectedItem() if _, ok := selectedItem.(*ModelItem); ok { return v } @@ -127,7 +127,7 @@ func (f *ModelsList) SelectPrev() (v bool) { func (f *ModelsList) SelectFirst() (v bool) { v = f.List.SelectFirst() for { - selectedItem := f.List.SelectedItem() + selectedItem := f.SelectedItem() if _, ok := selectedItem.(*ModelItem); ok { return v } @@ -139,7 +139,7 @@ func (f *ModelsList) SelectFirst() (v bool) { func (f *ModelsList) SelectLast() (v bool) { v = f.List.SelectLast() for { - selectedItem := f.List.SelectedItem() + selectedItem := f.SelectedItem() if _, ok := selectedItem.(*ModelItem); ok { return v } @@ -149,18 +149,18 @@ func (f *ModelsList) SelectLast() (v bool) { // IsSelectedFirst checks if the selected item is the first model item. func (f *ModelsList) IsSelectedFirst() bool { - originalIndex := f.List.Selected() + originalIndex := f.Selected() f.SelectFirst() - isFirst := f.List.Selected() == originalIndex + isFirst := f.Selected() == originalIndex f.List.SetSelected(originalIndex) return isFirst } // IsSelectedLast checks if the selected item is the last model item. func (f *ModelsList) IsSelectedLast() bool { - originalIndex := f.List.Selected() + originalIndex := f.Selected() f.SelectLast() - isLast := f.List.Selected() == originalIndex + isLast := f.Selected() == originalIndex f.List.SetSelected(originalIndex) return isLast } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index d7d8d6d8a38432b77b8ce49e7d04961a6e489cc8..077de541f5a4f40615765166cd4446acc2678429 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -279,6 +279,9 @@ type Styles struct { AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold) AgentPrompt lipgloss.Style // Agent prompt text + // Agentic fetch styles + AgenticFetchPromptTag lipgloss.Style // Agentic fetch prompt tag (green background, bold) + // Todo styles TodoRatio lipgloss.Style // Todo ratio (e.g., "2/5") TodoCompletedIcon lipgloss.Style // Completed todo icon @@ -1012,6 +1015,9 @@ func DefaultStyles() Styles { s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white) s.Tool.AgentPrompt = s.Muted + // Agentic fetch styles + s.Tool.AgenticFetchPromptTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(green).Foreground(border) + // Todo styles s.Tool.TodoRatio = base.Foreground(blueDark) s.Tool.TodoCompletedIcon = base.Foreground(green) From 17e66163283638c8de1bf73557e9c0766b38062d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 6 Jan 2026 12:05:08 -0500 Subject: [PATCH 112/335] feat(ui): status: add status bar with info messages and help toggle --- internal/ui/model/status.go | 106 +++++++++++++++++++++++++++++++++++ internal/ui/model/ui.go | 45 ++++++++------- internal/ui/styles/styles.go | 29 ++++++++++ internal/uiutil/uiutil.go | 6 ++ 4 files changed, 166 insertions(+), 20 deletions(-) create mode 100644 internal/ui/model/status.go diff --git a/internal/ui/model/status.go b/internal/ui/model/status.go new file mode 100644 index 0000000000000000000000000000000000000000..a3371d27d2f19f3236734ea8a31602fa5d518e62 --- /dev/null +++ b/internal/ui/model/status.go @@ -0,0 +1,106 @@ +package model + +import ( + "time" + + "charm.land/bubbles/v2/help" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" +) + +// DefaultStatusTTL is the default time-to-live for status messages. +const DefaultStatusTTL = 5 * time.Second + +// Status is the status bar and help model. +type Status struct { + com *common.Common + help help.Model + helpKm help.KeyMap + msg uiutil.InfoMsg +} + +// NewStatus creates a new status bar and help model. +func NewStatus(com *common.Common, km help.KeyMap) *Status { + s := new(Status) + s.com = com + s.help = help.New() + s.help.Styles = com.Styles.Help + s.helpKm = km + return s +} + +// SetInfoMsg sets the status info message. +func (s *Status) SetInfoMsg(msg uiutil.InfoMsg) { + s.msg = msg +} + +// ClearInfoMsg clears the status info message. +func (s *Status) ClearInfoMsg() { + s.msg = uiutil.InfoMsg{} +} + +// SetWidth sets the width of the status bar and help view. +func (s *Status) SetWidth(width int) { + s.help.SetWidth(width) +} + +// ShowingAll returns whether the full help view is shown. +func (s *Status) ShowingAll() bool { + return s.help.ShowAll +} + +// ToggleHelp toggles the full help view. +func (s *Status) ToggleHelp() { + s.help.ShowAll = !s.help.ShowAll +} + +// Draw draws the status bar onto the screen. +func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) { + helpView := s.com.Styles.Status.Help.Render(s.help.View(s.helpKm)) + uv.NewStyledString(helpView).Draw(scr, area) + + // Render notifications + if s.msg.IsEmpty() { + return + } + + var indStyle lipgloss.Style + var msgStyle lipgloss.Style + switch s.msg.Type { + case uiutil.InfoTypeError: + indStyle = s.com.Styles.Status.ErrorIndicator + msgStyle = s.com.Styles.Status.ErrorMessage + case uiutil.InfoTypeWarn: + indStyle = s.com.Styles.Status.WarnIndicator + msgStyle = s.com.Styles.Status.WarnMessage + case uiutil.InfoTypeUpdate: + indStyle = s.com.Styles.Status.UpdateIndicator + msgStyle = s.com.Styles.Status.UpdateMessage + case uiutil.InfoTypeInfo: + indStyle = s.com.Styles.Status.InfoIndicator + msgStyle = s.com.Styles.Status.InfoMessage + case uiutil.InfoTypeSuccess: + indStyle = s.com.Styles.Status.SuccessIndicator + msgStyle = s.com.Styles.Status.SuccessMessage + } + + ind := indStyle.String() + messageWidth := area.Dx() - lipgloss.Width(ind) + msg := ansi.Truncate(s.msg.Msg, messageWidth, "…") + info := msgStyle.Width(messageWidth).Render(msg) + + // Draw the info message over the help view + uv.NewStyledString(ind+info).Draw(scr, area) +} + +// clearInfoMsgCmd returns a command that clears the info message after the +// given TTL. +func clearInfoMsgCmd(ttl time.Duration) tea.Cmd { + return tea.Tick(ttl, func(time.Time) tea.Msg { + return uiutil.ClearStatusMsg{} + }) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 6072c4292c07aebbd92a1329944bc20b9826bb48..3540a946d095ca356b8a1a21cbb81a6faeb9be0a 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -82,7 +82,7 @@ type UI struct { keyenh tea.KeyboardEnhancementsMsg dialog *dialog.Overlay - help help.Model + status *Status // header is the last cached header logo header string @@ -137,13 +137,14 @@ func New(com *common.Common) *UI { com: com, dialog: dialog.NewOverlay(), keyMap: DefaultKeyMap(), - help: help.New(), focus: uiFocusNone, state: uiConfigure, textarea: ta, chat: ch, } + status := NewStatus(com, ui) + // set onboarding state defaults ui.onboarding.yesInitializeSelected = true @@ -162,7 +163,7 @@ func New(com *common.Common) *UI { ui.setEditorPrompt(false) ui.randomizePlaceholders() ui.textarea.Placeholder = ui.readyPlaceholder - ui.help.Styles = com.Styles.Help + ui.status = status return ui } @@ -338,6 +339,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case openEditorMsg: m.textarea.SetValue(msg.Text) m.textarea.MoveToEnd() + case uiutil.InfoMsg: + m.status.SetInfoMsg(msg) + ttl := msg.TTL + if ttl <= 0 { + ttl = DefaultStatusTTL + } + cmds = append(cmds, clearInfoMsgCmd(ttl)) + case uiutil.ClearStatusMsg: + m.status.ClearInfoMsg() } // This logic gets triggered on any message type, but should it? @@ -480,7 +490,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { handleGlobalKeys := func(msg tea.KeyPressMsg) bool { switch { case key.Matches(msg, m.keyMap.Help): - m.help.ShowAll = !m.help.ShowAll + m.status.ToggleHelp() m.updateLayoutAndSize() return true case key.Matches(msg, m.keyMap.Commands): @@ -569,7 +579,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { cmds = append(cmds, uiutil.ReportError(err)) } case dialog.ToggleHelpMsg: - m.help.ShowAll = !m.help.ShowAll + m.status.ToggleHelp() m.dialog.CloseDialog(dialog.CommandsID) case dialog.QuitMsg: cmds = append(cmds, tea.Quit) @@ -815,9 +825,8 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { editor.Draw(scr, layout.editor) } - // Add help layer - help := uv.NewStyledString(m.help.View(m)) - help.Draw(scr, layout.help) + // Add status and help layer + m.status.Draw(scr, layout.status) // Debugging rendering (visually see when the tui rerenders) if os.Getenv("CRUSH_UI_DEBUG") == "true" { @@ -1077,8 +1086,8 @@ func (m *UI) updateLayoutAndSize() { // updateSize updates the sizes of UI components based on the current layout. func (m *UI) updateSize() { - // Set help width - m.help.SetWidth(m.layout.help.Dx()) + // Set status width + m.status.SetWidth(m.layout.status.Dx()) m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy()) m.textarea.SetWidth(m.layout.editor.Dx()) @@ -1115,18 +1124,16 @@ func (m *UI) generateLayout(w, h int) layout { headerHeight := 4 var helpKeyMap help.KeyMap = m - if m.help.ShowAll { + if m.status.ShowingAll() { for _, row := range helpKeyMap.FullHelp() { helpHeight = max(helpHeight, len(row)) } } // Add app margins - appRect := area + appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight)) appRect.Min.X += 1 - appRect.Min.Y += 1 appRect.Max.X -= 1 - appRect.Max.Y -= 1 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) { // extra padding on left and right for these states @@ -1134,11 +1141,9 @@ func (m *UI) generateLayout(w, h int) layout { appRect.Max.X -= 1 } - appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight)) - layout := layout{ - area: area, - help: helpRect, + area: area, + status: helpRect, } // Handle different app states @@ -1242,8 +1247,8 @@ type layout struct { // sidebar is the area for the sidebar. sidebar uv.Rectangle - // help is the area for the help view. - help uv.Rectangle + // status is the area for the status view. + status uv.Rectangle } func (m *UI) openEditor(value string) tea.Cmd { diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index a0c7ad418a0c8d4dbeb041cfd48d5a16fd110622..394889039c0d71602cd07089db53e44165dba489 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -303,6 +303,23 @@ type Styles struct { Commands struct{} } + + // Status bar and help + Status struct { + Help lipgloss.Style + + ErrorIndicator lipgloss.Style + WarnIndicator lipgloss.Style + InfoIndicator lipgloss.Style + UpdateIndicator lipgloss.Style + SuccessIndicator lipgloss.Style + + ErrorMessage lipgloss.Style + WarnMessage lipgloss.Style + InfoMessage lipgloss.Style + UpdateMessage lipgloss.Style + SuccessMessage lipgloss.Style + } } // ChromaTheme converts the current markdown chroma styles to a chroma @@ -1110,6 +1127,18 @@ func DefaultStyles() Styles { s.Dialog.List = base.Margin(0, 0, 1, 0) + s.Status.Help = lipgloss.NewStyle().Padding(0, 1) + s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!") + s.Status.InfoIndicator = s.Status.SuccessIndicator + s.Status.UpdateIndicator = s.Status.SuccessIndicator.SetString("HEY!") + s.Status.WarnIndicator = s.Status.SuccessIndicator.Foreground(bgOverlay).Background(yellow).SetString("WARNING") + s.Status.ErrorIndicator = s.Status.SuccessIndicator.Foreground(bgBase).Background(red).SetString("ERROR") + s.Status.SuccessMessage = base.Foreground(bgSubtle).Background(greenDark).Padding(0, 1) + s.Status.InfoMessage = s.Status.SuccessMessage + s.Status.UpdateMessage = s.Status.SuccessMessage + s.Status.WarnMessage = s.Status.SuccessMessage.Foreground(bgOverlay).Background(warning) + s.Status.ErrorMessage = s.Status.SuccessMessage.Foreground(white).Background(redDark) + return s } diff --git a/internal/uiutil/uiutil.go b/internal/uiutil/uiutil.go index efd89dda69f780b354777916b459675154780372..92ae8c97937793e0643cdcb6a930216116fee2dd 100644 --- a/internal/uiutil/uiutil.go +++ b/internal/uiutil/uiutil.go @@ -65,6 +65,12 @@ type ( ClearStatusMsg struct{} ) +// IsEmpty checks if the [InfoMsg] is empty. +func (m InfoMsg) IsEmpty() bool { + var zero InfoMsg + return m == zero +} + // 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. From bbce33fb50e8b53e02c0d54453047b5789ea64af Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 6 Jan 2026 19:15:31 +0100 Subject: [PATCH 113/335] chore: rename isSpinning to spinning --- internal/ui/chat/agent.go | 4 ++-- internal/ui/chat/tools.go | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/internal/ui/chat/agent.go b/internal/ui/chat/agent.go index baa9633623be16fab85d5e0b7b190a4173f1be15..f13f9f03f69df57ac61406f69aa3fe30c22d8f07 100644 --- a/internal/ui/chat/agent.go +++ b/internal/ui/chat/agent.go @@ -46,7 +46,7 @@ func NewAgentToolMessageItem( t := &AgentToolMessageItem{} t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled) // For the agent tool we keep spinning until the tool call is finished. - t.isSpinningFn = func(state IsSpinningState) bool { + t.spinningFunc = func(state SpinningState) bool { return state.Result == nil && !state.Canceled } return t @@ -190,7 +190,7 @@ func NewAgenticFetchToolMessageItem( t := &AgenticFetchToolMessageItem{} t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled) // For the agentic fetch tool we keep spinning until the tool call is finished. - t.isSpinningFn = func(state IsSpinningState) bool { + t.spinningFunc = func(state SpinningState) bool { return state.Result == nil && !state.Canceled } return t diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index abed4d12174f8e4b5b1c98d84fa92fbd7b7230c9..016fb1dc32ab81042623cf6750666eba1d42ecd9 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -50,16 +50,16 @@ type Compactable interface { SetCompact(compact bool) } -// IsSpinningState contains the state passed to IsSpinningFn for custom spinning logic. -type IsSpinningState struct { +// SpinningState contains the state passed to SpinningFunc for custom spinning logic. +type SpinningState struct { ToolCall message.ToolCall Result *message.ToolResult Canceled bool } -// IsSpinningFn is a function type for custom spinning logic. +// SpinningFunc is a function type for custom spinning logic. // Returns true if the tool should show the spinning animation. -type IsSpinningFn func(state IsSpinningState) bool +type SpinningFunc func(state SpinningState) bool // DefaultToolRenderContext implements the default [ToolRenderer] interface. type DefaultToolRenderContext struct{} @@ -130,9 +130,9 @@ type baseToolMessageItem struct { hasCappedWidth bool // isCompact indicates this tool should render in compact mode. isCompact bool - // isSpinningFn allows tools to override the default spinning logic. + // spinningFunc allows tools to override the default spinning logic. // If nil, uses the default: !toolCall.Finished && !canceled. - isSpinningFn IsSpinningFn + spinningFunc SpinningFunc sty *styles.Styles anim *anim.Anim @@ -349,8 +349,8 @@ func (t *baseToolMessageItem) SetPermissionGranted(granted bool) { // isSpinning returns true if the tool should show animation. func (t *baseToolMessageItem) isSpinning() bool { - if t.isSpinningFn != nil { - return t.isSpinningFn(IsSpinningState{ + if t.spinningFunc != nil { + return t.spinningFunc(SpinningState{ ToolCall: t.toolCall, Result: t.result, Canceled: t.canceled, @@ -359,9 +359,9 @@ func (t *baseToolMessageItem) isSpinning() bool { return !t.toolCall.Finished && !t.canceled } -// SetIsSpinningFn sets a custom function to determine if the tool should spin. -func (t *baseToolMessageItem) SetIsSpinningFn(fn IsSpinningFn) { - t.isSpinningFn = fn +// SetSpinningFunc sets a custom function to determine if the tool should spin. +func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) { + t.spinningFunc = fn } // ToggleExpanded toggles the expanded state of the thinking box. From 706e359b34d1a498a2569532f0b1006fabb169bc Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 7 Jan 2026 12:44:28 -0500 Subject: [PATCH 114/335] fix(ui): adjust app and help area margins --- 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 088d405991c2509ddc76899ffcaaae263dda4fde..6d093670b5296110e66667d1c27eb00759d7fa28 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1286,6 +1286,9 @@ func (m *UI) generateLayout(w, h int) layout { // Add app margins appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight)) + appRect.Min.Y += 1 + appRect.Max.Y -= 1 + helpRect.Min.Y -= 1 appRect.Min.X += 1 appRect.Max.X -= 1 From 599866a9c499a2227b0b97dd0161cc31b4cea93f Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 7 Jan 2026 17:03:31 -0300 Subject: [PATCH 115/335] refactor: use `cmp.Or` --- internal/ui/dialog/models.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index f0e0cb3b3e94f5749fe249b2758561f8ea615a9a..dcf7ca2bf5037eb9970abfbd9166928fcd462f2f 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -334,10 +334,7 @@ func (m *Models) setProviderItems() error { provider := p.ToProvider() // Add this unknown provider to the list - name := p.Name - if name == "" { - name = id - } + name := cmp.Or(p.Name, id) addedProviders[id] = true From e38ffc3804b85e5dd70c39063a7a9f553fbdff4b Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 7 Jan 2026 17:04:00 -0300 Subject: [PATCH 116/335] fix(models): ensure that we show unknown providers on the list --- internal/ui/dialog/models.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index dcf7ca2bf5037eb9970abfbd9166928fcd462f2f..7e568fb97e381ac984b0e7bf75c7282cc60e33f5 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -347,6 +347,9 @@ func (m *Models) setProviderItems() error { selectedItemID = item.ID() } } + if len(group.Items) > 0 { + groups = append(groups, group) + } } } From 23e0b06d0054203cf4deb033b18e48037ed99e5d Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 8 Jan 2026 12:03:03 -0300 Subject: [PATCH 117/335] feat: completions menu (#1781) Signed-off-by: Carlos Alexandro Becker --- .gitignore | 3 +- internal/ui/completions/completions.go | 271 +++++++++++++++++++++++++ internal/ui/completions/item.go | 185 +++++++++++++++++ internal/ui/completions/keys.go | 74 +++++++ internal/ui/list/filterable.go | 2 + internal/ui/list/list.go | 95 +++++++-- internal/ui/model/ui.go | 191 ++++++++++++++++- internal/ui/styles/styles.go | 12 ++ 8 files changed, 809 insertions(+), 24 deletions(-) create mode 100644 internal/ui/completions/completions.go create mode 100644 internal/ui/completions/item.go create mode 100644 internal/ui/completions/keys.go diff --git a/.gitignore b/.gitignore index 01510713f6c6886781775f35d27d95fa96d3ef2f..008dcff3153d850de53e4e792fb320355f0009ea 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,5 @@ Thumbs.db /tmp/ manpages/ -completions/ -!internal/tui/components/completions/ +completions/crush.*sh .prettierignore diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go new file mode 100644 index 0000000000000000000000000000000000000000..4e5f9f64d46512c29b69ea004a772d3ce89c3777 --- /dev/null +++ b/internal/ui/completions/completions.go @@ -0,0 +1,271 @@ +package completions + +import ( + "slices" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/ordered" +) + +const ( + minHeight = 1 + maxHeight = 10 + minWidth = 10 + maxWidth = 100 +) + +// SelectionMsg is sent when a completion is selected. +type SelectionMsg struct { + Value any + Insert 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 +} + +// Completions represents the completions popup component. +type Completions struct { + // Popup dimensions + width int + height int + + // State + open bool + query string + + // Key bindings + keyMap KeyMap + + // List component + list *list.FilterableList + + // Styling + normalStyle lipgloss.Style + focusedStyle lipgloss.Style + matchStyle lipgloss.Style +} + +// New creates a new completions component. +func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions { + l := list.NewFilterableList() + l.SetGap(0) + l.SetReverse(true) + + return &Completions{ + keyMap: DefaultKeyMap(), + list: l, + normalStyle: normalStyle, + focusedStyle: focusedStyle, + matchStyle: matchStyle, + } +} + +// IsOpen returns whether the completions popup is open. +func (c *Completions) IsOpen() bool { + return c.open +} + +// Query returns the current filter query. +func (c *Completions) Query() string { + return c.query +} + +// Size returns the visible size of the popup. +func (c *Completions) Size() (width, height int) { + visible := len(c.list.VisibleItems()) + return c.width, min(visible, c.height) +} + +// KeyMap returns the key bindings. +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 { + return func() tea.Msg { + files, _, _ := fsext.ListDirectory(".", nil, depth, limit) + slices.Sort(files) + return FilesLoadedMsg{Files: files} + } +} + +// SetFiles sets the file items on the completions popup. +func (c *Completions) SetFiles(files []string) { + items := make([]list.FilterableItem, 0, len(files)) + width := 0 + for _, file := range files { + file = strings.TrimPrefix(file, "./") + item := NewCompletionItem( + file, + FileCompletionValue{Path: file}, + c.normalStyle, + c.focusedStyle, + c.matchStyle, + ) + + width = max(width, ansi.StringWidth(file)) + items = append(items, item) + } + + c.open = true + c.query = "" + c.list.SetItems(items...) + c.list.SetFilter("") // Clear any previous filter. + c.list.Focus() + + c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth)) + c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight)) + c.list.SetSize(c.width, c.height) + c.list.SelectFirst() + c.list.ScrollToSelected() +} + +// Close closes the completions popup. +func (c *Completions) Close() tea.Cmd { + c.open = false + return func() tea.Msg { + return ClosedMsg{} + } +} + +// Filter filters the completions with the given query. +func (c *Completions) Filter(query string) { + if !c.open { + return + } + + if query == c.query { + return + } + + c.query = query + c.list.SetFilter(query) + + items := c.list.VisibleItems() + width := 0 + for _, item := range items { + width = max(width, ansi.StringWidth(item.(interface{ Text() string }).Text())) + } + c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth)) + c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight)) + c.list.SetSize(c.width, c.height) + c.list.SelectFirst() + c.list.ScrollToSelected() +} + +// HasItems returns whether there are visible items. +func (c *Completions) HasItems() bool { + return len(c.list.VisibleItems()) > 0 +} + +// Update handles key events for the completions. +func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Cmd, bool) { + if !c.open { + return nil, false + } + + switch { + case key.Matches(msg, c.keyMap.Up): + c.selectPrev() + return nil, true + + case key.Matches(msg, c.keyMap.Down): + c.selectNext() + return nil, true + + case key.Matches(msg, c.keyMap.UpInsert): + c.selectPrev() + return c.selectCurrent(true), true + + case key.Matches(msg, c.keyMap.DownInsert): + c.selectNext() + return c.selectCurrent(true), true + + case key.Matches(msg, c.keyMap.Select): + return c.selectCurrent(false), true + + case key.Matches(msg, c.keyMap.Cancel): + return c.Close(), true + } + + return nil, false +} + +// selectPrev selects the previous item with circular navigation. +func (c *Completions) selectPrev() { + items := c.list.VisibleItems() + if len(items) == 0 { + return + } + if !c.list.SelectPrev() { + c.list.WrapToEnd() + } + c.list.ScrollToSelected() +} + +// selectNext selects the next item with circular navigation. +func (c *Completions) selectNext() { + items := c.list.VisibleItems() + if len(items) == 0 { + return + } + if !c.list.SelectNext() { + c.list.WrapToStart() + } + c.list.ScrollToSelected() +} + +// selectCurrent returns a command with the currently selected item. +func (c *Completions) selectCurrent(insert bool) tea.Cmd { + items := c.list.VisibleItems() + if len(items) == 0 { + return nil + } + + selected := c.list.Selected() + if selected < 0 || selected >= len(items) { + return nil + } + + item, ok := items[selected].(*CompletionItem) + if !ok { + return nil + } + + if !insert { + c.open = false + } + + return func() tea.Msg { + return SelectionMsg{ + Value: item.Value(), + Insert: insert, + } + } +} + +// Render renders the completions popup. +func (c *Completions) Render() string { + if !c.open { + return "" + } + + items := c.list.VisibleItems() + if len(items) == 0 { + return "" + } + + return c.list.Render() +} diff --git a/internal/ui/completions/item.go b/internal/ui/completions/item.go new file mode 100644 index 0000000000000000000000000000000000000000..1114083fd1a118649921ead3ea2288d6e6085632 --- /dev/null +++ b/internal/ui/completions/item.go @@ -0,0 +1,185 @@ +package completions + +import ( + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/x/ansi" + "github.com/rivo/uniseg" + "github.com/sahilm/fuzzy" +) + +// FileCompletionValue represents a file path completion value. +type FileCompletionValue struct { + Path string +} + +// CompletionItem represents an item in the completions list. +type CompletionItem struct { + text string + value any + match fuzzy.Match + focused bool + cache map[int]string + + // Styles + normalStyle lipgloss.Style + focusedStyle lipgloss.Style + matchStyle lipgloss.Style +} + +// NewCompletionItem creates a new completion item. +func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem { + return &CompletionItem{ + text: text, + value: value, + normalStyle: normalStyle, + focusedStyle: focusedStyle, + matchStyle: matchStyle, + } +} + +// Text returns the display text of the item. +func (c *CompletionItem) Text() string { + return c.text +} + +// Value returns the value of the item. +func (c *CompletionItem) Value() any { + return c.value +} + +// Filter implements [list.FilterableItem]. +func (c *CompletionItem) Filter() string { + return c.text +} + +// SetMatch implements [list.MatchSettable]. +func (c *CompletionItem) SetMatch(m fuzzy.Match) { + c.cache = nil + c.match = m +} + +// SetFocused implements [list.Focusable]. +func (c *CompletionItem) SetFocused(focused bool) { + if c.focused != focused { + c.cache = nil + } + c.focused = focused +} + +// Render implements [list.Item]. +func (c *CompletionItem) Render(width int) string { + return renderItem( + c.normalStyle, + c.focusedStyle, + c.matchStyle, + c.text, + c.focused, + width, + c.cache, + &c.match, + ) +} + +func renderItem( + normalStyle, focusedStyle, matchStyle lipgloss.Style, + text string, + focused bool, + width int, + cache map[int]string, + match *fuzzy.Match, +) string { + if cache == nil { + cache = make(map[int]string) + } + + cached, ok := cache[width] + if ok { + return cached + } + + innerWidth := width - 2 // Account for padding + // Truncate if needed. + if ansi.StringWidth(text) > innerWidth { + text = ansi.Truncate(text, innerWidth, "…") + } + + // Select base style. + style := normalStyle + matchStyle = matchStyle.Background(style.GetBackground()) + if focused { + style = focusedStyle + matchStyle = matchStyle.Background(style.GetBackground()) + } + + // Render full-width text with background. + content := style.Padding(0, 1).Width(width).Render(text) + + // Apply match highlighting using StyleRanges. + if len(match.MatchedIndexes) > 0 { + var ranges []lipgloss.Range + for _, rng := range matchedRanges(match.MatchedIndexes) { + start, stop := bytePosToVisibleCharPos(text, rng) + // Offset by 1 for the padding space. + ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, matchStyle)) + } + content = lipgloss.StyleRanges(content, ranges...) + } + + cache[width] = content + return content +} + +// matchedRanges converts a list of match indexes into contiguous ranges. +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 +} + +// bytePosToVisibleCharPos converts byte positions to visible character positions. +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 +} + +// Ensure CompletionItem implements the required interfaces. +var ( + _ list.Item = (*CompletionItem)(nil) + _ list.FilterableItem = (*CompletionItem)(nil) + _ list.MatchSettable = (*CompletionItem)(nil) + _ list.Focusable = (*CompletionItem)(nil) +) diff --git a/internal/ui/completions/keys.go b/internal/ui/completions/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..d150f1a96b05018bfeaf6fea0b45d2c5ea65ac06 --- /dev/null +++ b/internal/ui/completions/keys.go @@ -0,0 +1,74 @@ +package completions + +import ( + "charm.land/bubbles/v2/key" +) + +// KeyMap defines the key bindings for the completions component. +type KeyMap struct { + Down, + Up, + Select, + Cancel key.Binding + DownInsert, + UpInsert key.Binding +} + +// DefaultKeyMap returns the default key bindings for completions. +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 returns all key bindings as a slice. +func (k KeyMap) KeyBindings() []key.Binding { + return []key.Binding{ + k.Down, + k.Up, + k.Select, + k.Cancel, + } +} + +// FullHelp returns the full help for the key bindings. +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 returns the short help for the key bindings. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Up, + k.Down, + } +} diff --git a/internal/ui/list/filterable.go b/internal/ui/list/filterable.go index de78041e3c2666830b6f5ce695472d46448abf0f..d3c227f0234028aea22fcc397d861c263cab034a 100644 --- a/internal/ui/list/filterable.go +++ b/internal/ui/list/filterable.go @@ -68,6 +68,8 @@ func (f *FilterableList) PrependItems(items ...FilterableItem) { // SetFilter sets the filter query and updates the list items. func (f *FilterableList) SetFilter(q string) { f.query = q + f.List.SetItems(f.VisibleItems()...) + f.ScrollToTop() } // FilterableItemsSource is a type that implements [fuzzy.Source] for filtering diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index cd2c36c0c8b99d8d9a4dca7b7e1a69a0d17705ed..8806551c537ecbfdcba8169bc05d7de79183b0ba 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -17,6 +17,9 @@ type List struct { // Gap between items (0 or less means no gap) gap int + // show list in reverse order + reverse bool + // Focus and selection state focused bool selectedIdx int // The current selected index -1 means no selection @@ -63,6 +66,11 @@ func (l *List) SetGap(gap int) { l.gap = gap } +// SetReverse shows the list in reverse order. +func (l *List) SetReverse(reverse bool) { + l.reverse = reverse +} + // Width returns the width of the list viewport. func (l *List) Width() int { return l.width @@ -126,6 +134,10 @@ func (l *List) ScrollBy(lines int) { return } + if l.reverse { + lines = -lines + } + if lines > 0 { // Scroll down // Calculate from the bottom how many lines needed to anchor the last @@ -269,6 +281,13 @@ func (l *List) Render() string { lines = lines[:l.height] } + if l.reverse { + // Reverse the lines so the list renders bottom-to-top. + for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { + lines[i], lines[j] = lines[j], lines[i] + } + } + return strings.Join(lines, "\n") } @@ -440,12 +459,21 @@ func (l *List) IsSelectedLast() bool { return l.selectedIdx == len(l.items)-1 } -// SelectPrev selects the previous item in the list. +// SelectPrev selects the visually previous item (moves toward visual top). // It returns whether the selection changed. func (l *List) SelectPrev() bool { - if l.selectedIdx > 0 { - l.selectedIdx-- - return true + if l.reverse { + // In reverse, visual up = higher index + if l.selectedIdx < len(l.items)-1 { + l.selectedIdx++ + return true + } + } else { + // Normal: visual up = lower index + if l.selectedIdx > 0 { + l.selectedIdx-- + return true + } } return false } @@ -453,9 +481,18 @@ func (l *List) SelectPrev() bool { // SelectNext selects the next item in the list. // It returns whether the selection changed. func (l *List) SelectNext() bool { - if l.selectedIdx < len(l.items)-1 { - l.selectedIdx++ - return true + if l.reverse { + // In reverse, visual down = lower index + if l.selectedIdx > 0 { + l.selectedIdx-- + return true + } + } else { + // Normal: visual down = higher index + if l.selectedIdx < len(l.items)-1 { + l.selectedIdx++ + return true + } } return false } @@ -463,21 +500,49 @@ func (l *List) SelectNext() bool { // SelectFirst selects the first item in the list. // It returns whether the selection changed. func (l *List) SelectFirst() bool { - if len(l.items) > 0 { - l.selectedIdx = 0 - return true + if len(l.items) == 0 { + return false } - return false + l.selectedIdx = 0 + return true } -// SelectLast selects the last item in the list. +// SelectLast selects the last item in the list (highest index). // It returns whether the selection changed. func (l *List) SelectLast() bool { - if len(l.items) > 0 { + if len(l.items) == 0 { + return false + } + l.selectedIdx = len(l.items) - 1 + return true +} + +// WrapToStart wraps selection to the visual start (for circular navigation). +// In normal mode, this is index 0. In reverse mode, this is the highest index. +func (l *List) WrapToStart() bool { + if len(l.items) == 0 { + return false + } + if l.reverse { l.selectedIdx = len(l.items) - 1 - return true + } else { + l.selectedIdx = 0 } - return false + return true +} + +// WrapToEnd wraps selection to the visual end (for circular navigation). +// In normal mode, this is the highest index. In reverse mode, this is index 0. +func (l *List) WrapToEnd() bool { + if len(l.items) == 0 { + return false + } + if l.reverse { + l.selectedIdx = 0 + } else { + l.selectedIdx = len(l.items) - 1 + } + return true } // SelectedItem returns the currently selected item. It may be nil if no item diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 6d093670b5296110e66667d1c27eb00759d7fa28..54f453c3ec90b05721bb4c2ab8ab5442d8da6655 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -30,6 +30,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/completions" "github.com/charmbracelet/crush/internal/ui/dialog" "github.com/charmbracelet/crush/internal/ui/logo" "github.com/charmbracelet/crush/internal/ui/styles" @@ -103,6 +104,13 @@ type UI struct { readyPlaceholder string workingPlaceholder string + // Completions state + completions *completions.Completions + completionsOpen bool + completionsStartIndex int + completionsQuery string + completionsPositionStart image.Point // x,y where user typed '@' + // Chat components chat *Chat @@ -133,14 +141,22 @@ func New(com *common.Common) *UI { ch := NewChat(com) + // Completions component + comp := completions.New( + com.Styles.Completions.Normal, + com.Styles.Completions.Focused, + com.Styles.Completions.Match, + ) + ui := &UI{ - com: com, - dialog: dialog.NewOverlay(), - keyMap: DefaultKeyMap(), - focus: uiFocusNone, - state: uiConfigure, - textarea: ta, - chat: ch, + com: com, + dialog: dialog.NewOverlay(), + keyMap: DefaultKeyMap(), + focus: uiFocusNone, + state: uiConfigure, + textarea: ta, + chat: ch, + completions: comp, } status := NewStatus(com, ui) @@ -335,6 +351,21 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } } + case completions.SelectionMsg: + // Handle file completion selection. + if item, ok := msg.Value.(completions.FileCompletionValue); ok { + m.insertFileCompletion(item.Path) + } + if !msg.Insert { + m.closeCompletions() + } + case completions.FilesLoadedMsg: + // Handle async file loading for completions. + if m.completionsOpen { + m.completions.SetFiles(msg.Files) + } + case completions.ClosedMsg: + m.completionsOpen = false case tea.KeyPressMsg: if cmd := m.handleKeyPressMsg(msg); cmd != nil { cmds = append(cmds, cmd) @@ -775,6 +806,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { case uiChat, uiLanding, uiChatCompact: switch m.focus { case uiFocusEditor: + // Handle completions if open. + if m.completionsOpen { + if cmd, ok := m.completions.Update(msg); ok { + cmds = append(cmds, cmd) + return tea.Batch(cmds...) + } + } + switch { case key.Matches(msg, m.keyMap.Editor.SendMessage): value := m.textarea.Value() @@ -823,15 +862,57 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { cmds = append(cmds, m.openEditor(m.textarea.Value())) case key.Matches(msg, m.keyMap.Editor.Newline): m.textarea.InsertRune('\n') + m.closeCompletions() default: if handleGlobalKeys(msg) { // Handle global keys first before passing to textarea. break } + // Check for @ trigger before passing to textarea. + curValue := m.textarea.Value() + curIdx := len(curValue) + + // Trigger completions on @. + if msg.String() == "@" && !m.completionsOpen { + // Only show if beginning of prompt or after whitespace. + if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) { + m.completionsOpen = true + m.completionsQuery = "" + m.completionsStartIndex = curIdx + m.completionsPositionStart = m.completionsPosition() + depth, limit := m.com.Config().Options.TUI.Completions.Limits() + cmds = append(cmds, m.completions.OpenWithFiles(depth, limit)) + } + } + ta, cmd := m.textarea.Update(msg) m.textarea = ta cmds = append(cmds, cmd) + + // After updating textarea, check if we need to filter completions. + // Skip filtering on the initial @ keystroke since items are loading async. + if m.completionsOpen && msg.String() != "@" { + newValue := m.textarea.Value() + newIdx := len(newValue) + + // Close completions if cursor moved before start. + if newIdx <= m.completionsStartIndex { + m.closeCompletions() + } else if msg.String() == "space" { + // Close on space. + m.closeCompletions() + } else { + // Extract current word and filter. + word := m.textareaWord() + if strings.HasPrefix(word, "@") { + m.completionsQuery = word[1:] + m.completions.Filter(m.completionsQuery) + } else if m.completionsOpen { + m.closeCompletions() + } + } + } } case uiFocusMain: switch { @@ -982,6 +1063,26 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { // Add status and help layer m.status.Draw(scr, layout.status) + // Draw completions popup if open + if m.completionsOpen && m.completions.HasItems() { + w, h := m.completions.Size() + x := m.completionsPositionStart.X + y := m.completionsPositionStart.Y - h + + screenW := area.Dx() + if x+w > screenW { + x = screenW - w + } + x = max(0, x) + y = max(0, y) + + completionsView := uv.NewStyledString(m.completions.Render()) + completionsView.Draw(scr, image.Rectangle{ + Min: image.Pt(x, y), + Max: image.Pt(x+w, y+h), + }) + } + // Debugging rendering (visually see when the tui rerenders) if os.Getenv("CRUSH_UI_DEBUG") == "true" { debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2) @@ -1489,6 +1590,82 @@ func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string { return t.EditorPromptYoloDotsBlurred.Render() } +// closeCompletions closes the completions popup and resets state. +func (m *UI) closeCompletions() { + m.completionsOpen = false + m.completionsQuery = "" + m.completionsStartIndex = 0 + 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) { + value := m.textarea.Value() + word := m.textareaWord() + + // Find the @ and query to replace. + if m.completionsStartIndex > len(value) { + return + } + + // Build the new value: everything before @, the path, everything after query. + endIdx := m.completionsStartIndex + len(word) + if endIdx > len(value) { + endIdx = len(value) + } + + newValue := value[:m.completionsStartIndex] + path + value[endIdx:] + m.textarea.SetValue(newValue) + // XXX: This will always move the cursor to the end of the textarea. + m.textarea.MoveToEnd() + + // Add file as attachment. + content, err := os.ReadFile(path) + if err != nil { + // If it fails, let the LLM handle it later. + return + } + + m.attachments = append(m.attachments, message.Attachment{ + FilePath: path, + FileName: filepath.Base(path), + MimeType: mimeOf(content), + Content: content, + }) +} + +// completionsPosition returns the X and Y position for the completions popup. +func (m *UI) completionsPosition() image.Point { + cur := m.textarea.Cursor() + if cur == nil { + return image.Point{ + X: m.layout.editor.Min.X, + Y: m.layout.editor.Min.Y, + } + } + return image.Point{ + X: cur.X + m.layout.editor.Min.X, + Y: m.layout.editor.Min.Y + cur.Y, + } +} + +// textareaWord returns the current word at the cursor position. +func (m *UI) textareaWord() string { + return m.textarea.Word() +} + +// isWhitespace returns true if the byte is a whitespace character. +func isWhitespace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} + +// mimeOf detects the MIME type of the given content. +func mimeOf(content []byte) string { + mimeBufferSize := min(512, len(content)) + return http.DetectContentType(content[:mimeBufferSize]) +} + var readyPlaceholders = [...]string{ "Ready!", "Ready...", diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 55248f703400083e4eae36ca9405296111c6db29..0f0de03cdba36056e82910a5423aac56a80fbb04 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -330,6 +330,13 @@ type Styles struct { UpdateMessage lipgloss.Style SuccessMessage lipgloss.Style } + + // Completions popup styles + Completions struct { + Normal lipgloss.Style + Focused lipgloss.Style + Match lipgloss.Style + } } // ChromaTheme converts the current markdown chroma styles to a chroma @@ -1160,6 +1167,11 @@ func DefaultStyles() Styles { s.Status.WarnMessage = s.Status.SuccessMessage.Foreground(bgOverlay).Background(warning) s.Status.ErrorMessage = s.Status.SuccessMessage.Foreground(white).Background(redDark) + // Completions styles + s.Completions.Normal = base.Background(bgSubtle).Foreground(fgBase) + s.Completions.Focused = base.Background(primary).Foreground(white) + s.Completions.Match = base.Underline(true) + return s } From fb78b802586c10dc40dbdd2602ef62a3c51f08c4 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 8 Jan 2026 10:14:16 -0500 Subject: [PATCH 118/335] fix(ui): dialogs: ensure returned commands are executed --- internal/ui/dialog/commands.go | 4 +++- internal/ui/dialog/models.go | 4 +++- internal/ui/dialog/sessions.go | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index f87547641b6b5585abcb8d5ffe77a84d8c632041..b560fb8478f267b9ec3aa26938787aa2954a2e39 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -176,7 +176,9 @@ func (c *Commands) Update(msg tea.Msg) tea.Msg { c.list.SetFilter(value) c.list.ScrollToTop() c.list.SetSelected(0) - return cmd + if cmd != nil { + return cmd() + } } } return nil diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 7e568fb97e381ac984b0e7bf75c7282cc60e33f5..1830a0880975b136446ac5bb45dae0d558fc2795 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -222,7 +222,9 @@ func (m *Models) Update(msg tea.Msg) tea.Msg { value := m.input.Value() m.list.SetFilter(value) m.list.ScrollToSelected() - return cmd + if cmd != nil { + return cmd() + } } } return nil diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index daeec6e208dee88883b7d86282769093fef67858..13306cc87945d1dafe2240c08d04a2200d7c83b9 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -140,7 +140,9 @@ func (s *Session) Update(msg tea.Msg) tea.Msg { s.list.SetFilter(value) s.list.ScrollToTop() s.list.SetSelected(0) - return cmd + if cmd != nil { + return cmd() + } } } return nil From 6c0573915f184d935786ef8ecdfc18571f6dc0d9 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 8 Jan 2026 10:34:49 -0500 Subject: [PATCH 119/335] fix(ui): completions: simplify completions popup message handling --- internal/ui/completions/completions.go | 33 +++++++++----------------- internal/ui/model/ui.go | 32 +++++++++++-------------- 2 files changed, 25 insertions(+), 40 deletions(-) diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go index 4e5f9f64d46512c29b69ea004a772d3ce89c3777..7e854a6b55ef99691e30ba3566074af3cba2982d 100644 --- a/internal/ui/completions/completions.go +++ b/internal/ui/completions/completions.go @@ -29,11 +29,6 @@ type SelectionMsg struct { // 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 -} - // Completions represents the completions popup component. type Completions struct { // Popup dimensions @@ -93,12 +88,10 @@ func (c *Completions) KeyMap() KeyMap { } // OpenWithFiles opens the completions with file items from the filesystem. -func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd { - return func() tea.Msg { - files, _, _ := fsext.ListDirectory(".", nil, depth, limit) - slices.Sort(files) - return FilesLoadedMsg{Files: files} - } +func (c *Completions) OpenWithFiles(depth, limit int) { + files, _, _ := fsext.ListDirectory(".", nil, depth, limit) + slices.Sort(files) + c.SetFiles(files) } // SetFiles sets the file items on the completions popup. @@ -133,11 +126,9 @@ func (c *Completions) SetFiles(files []string) { } // Close closes the completions popup. -func (c *Completions) Close() tea.Cmd { +func (c *Completions) Close() tea.Msg { c.open = false - return func() tea.Msg { - return ClosedMsg{} - } + return ClosedMsg{} } // Filter filters the completions with the given query. @@ -171,7 +162,7 @@ func (c *Completions) HasItems() bool { } // Update handles key events for the completions. -func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Cmd, bool) { +func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) { if !c.open { return nil, false } @@ -228,7 +219,7 @@ func (c *Completions) selectNext() { } // selectCurrent returns a command with the currently selected item. -func (c *Completions) selectCurrent(insert bool) tea.Cmd { +func (c *Completions) selectCurrent(insert bool) tea.Msg { items := c.list.VisibleItems() if len(items) == 0 { return nil @@ -248,11 +239,9 @@ func (c *Completions) selectCurrent(insert bool) tea.Cmd { c.open = false } - return func() tea.Msg { - return SelectionMsg{ - Value: item.Value(), - Insert: insert, - } + return SelectionMsg{ + Value: item.Value(), + Insert: insert, } } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 54f453c3ec90b05721bb4c2ab8ab5442d8da6655..f49f8bbdcc455caf6f2228938a1d95e1e24c8c21 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -351,21 +351,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } } - case completions.SelectionMsg: - // Handle file completion selection. - if item, ok := msg.Value.(completions.FileCompletionValue); ok { - m.insertFileCompletion(item.Path) - } - if !msg.Insert { - m.closeCompletions() - } - case completions.FilesLoadedMsg: - // Handle async file loading for completions. - if m.completionsOpen { - m.completions.SetFiles(msg.Files) - } - case completions.ClosedMsg: - m.completionsOpen = false case tea.KeyPressMsg: if cmd := m.handleKeyPressMsg(msg); cmd != nil { cmds = append(cmds, cmd) @@ -808,8 +793,19 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { case uiFocusEditor: // Handle completions if open. if m.completionsOpen { - if cmd, ok := m.completions.Update(msg); ok { - cmds = append(cmds, cmd) + 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 { + m.insertFileCompletion(item.Path) + } + if !msg.Insert { + m.closeCompletions() + } + case completions.ClosedMsg: + m.completionsOpen = false + } return tea.Batch(cmds...) } } @@ -882,7 +878,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)) + m.completions.OpenWithFiles(depth, limit) } } From 0c8cd47548296e9eaed766d8569411f9d3193f1f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 8 Jan 2026 10:51:46 -0500 Subject: [PATCH 120/335] fix(ui): completions: simplify Close method --- internal/ui/completions/completions.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go index 7e854a6b55ef99691e30ba3566074af3cba2982d..496cd5a56be71fcc7d713be7f9c384fd8c58a307 100644 --- a/internal/ui/completions/completions.go +++ b/internal/ui/completions/completions.go @@ -126,9 +126,8 @@ func (c *Completions) SetFiles(files []string) { } // Close closes the completions popup. -func (c *Completions) Close() tea.Msg { +func (c *Completions) Close() { c.open = false - return ClosedMsg{} } // Filter filters the completions with the given query. @@ -188,7 +187,8 @@ func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) { return c.selectCurrent(false), true case key.Matches(msg, c.keyMap.Cancel): - return c.Close(), true + c.Close() + return ClosedMsg{}, true } return nil, false From 01c052473d7c9e95dc7391c188ed8deb34ad3b95 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 8 Jan 2026 10:52:48 -0500 Subject: [PATCH 121/335] fix(ui): simplify suffix handling in message editor --- internal/ui/model/ui.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index f49f8bbdcc455caf6f2228938a1d95e1e24c8c21..f7010369b3592cf3f731c30a8eaf2a5ffed4adf7 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -813,9 +813,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { switch { case key.Matches(msg, m.keyMap.Editor.SendMessage): value := m.textarea.Value() - if strings.HasSuffix(value, "\\") { + if before, ok := strings.CutSuffix(value, "\\"); ok { // If the last character is a backslash, remove it and add a newline. - m.textarea.SetValue(strings.TrimSuffix(value, "\\")) + m.textarea.SetValue(before) break } From c4cdc5dee1ce6c86f9d86b03d8845120f8c804f6 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 8 Jan 2026 11:13:32 -0500 Subject: [PATCH 122/335] fix(ui): dry up up/down key binding in dialog commands --- internal/ui/dialog/commands.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index b560fb8478f267b9ec3aa26938787aa2954a2e39..da95a252c044378690f3cf3f8cfbdde8a124349b 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -31,6 +31,7 @@ type Commands struct { com *common.Common keyMap struct { Select, + UpDown, Next, Previous, Tab, @@ -87,6 +88,10 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) { key.WithKeys("enter", "ctrl+y"), key.WithHelp("enter", "confirm"), ) + c.keyMap.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ) c.keyMap.Next = key.NewBinding( key.WithKeys("down", "ctrl+n"), key.WithHelp("↓", "next item"), @@ -240,13 +245,9 @@ func (c *Commands) View() string { // ShortHelp implements [help.KeyMap]. func (c *Commands) ShortHelp() []key.Binding { - upDown := key.NewBinding( - key.WithKeys("up", "down"), - key.WithHelp("↑/↓", "choose"), - ) return []key.Binding{ c.keyMap.Tab, - upDown, + c.keyMap.UpDown, c.keyMap.Select, c.keyMap.Close, } From 6a5746354cd9998fabadf57dc9c9ce0a91023e4c Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 8 Jan 2026 11:25:47 -0500 Subject: [PATCH 123/335] fix(ui): completions: load files asynchronously --- internal/ui/completions/completions.go | 15 +++++++++++---- internal/ui/model/ui.go | 7 ++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go index 496cd5a56be71fcc7d713be7f9c384fd8c58a307..4a4f9d8133491b8a7b80df6066b9e86c7e852a85 100644 --- a/internal/ui/completions/completions.go +++ b/internal/ui/completions/completions.go @@ -29,6 +29,11 @@ type SelectionMsg struct { // 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 +} + // Completions represents the completions popup component. type Completions struct { // Popup dimensions @@ -88,10 +93,12 @@ func (c *Completions) KeyMap() KeyMap { } // OpenWithFiles opens the completions with file items from the filesystem. -func (c *Completions) OpenWithFiles(depth, limit int) { - files, _, _ := fsext.ListDirectory(".", nil, depth, limit) - slices.Sort(files) - c.SetFiles(files) +func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd { + return func() tea.Msg { + files, _, _ := fsext.ListDirectory(".", nil, depth, limit) + slices.Sort(files) + return FilesLoadedMsg{Files: files} + } } // SetFiles sets the file items on the completions popup. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index f7010369b3592cf3f731c30a8eaf2a5ffed4adf7..c06ea6f7ada0180c3a8ffbbeff92430a9c0ff641 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -371,6 +371,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, clearInfoMsgCmd(ttl)) case uiutil.ClearStatusMsg: m.status.ClearInfoMsg() + case completions.FilesLoadedMsg: + // Handle async file loading for completions. + if m.completionsOpen { + m.completions.SetFiles(msg.Files) + } } // This logic gets triggered on any message type, but should it? @@ -878,7 +883,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() - m.completions.OpenWithFiles(depth, limit) + cmds = append(cmds, m.completions.OpenWithFiles(depth, limit)) } } From b66db250ad9915f6957c79cd8a35c105e0812017 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 8 Jan 2026 14:45:16 -0300 Subject: [PATCH 124/335] feat: attachments (#1797) Signed-off-by: Carlos Alexandro Becker --- internal/ui/attachments/attachments.go | 135 +++++++++++++++++++ internal/ui/chat/messages.go | 9 +- internal/ui/chat/user.go | 60 ++------- internal/ui/model/ui.go | 178 ++++++++++++++++++------- internal/ui/styles/styles.go | 37 +++-- 5 files changed, 313 insertions(+), 106 deletions(-) create mode 100644 internal/ui/attachments/attachments.go diff --git a/internal/ui/attachments/attachments.go b/internal/ui/attachments/attachments.go new file mode 100644 index 0000000000000000000000000000000000000000..558c7576ee1edb3756be3dc7b4ccfcb89a5597b7 --- /dev/null +++ b/internal/ui/attachments/attachments.go @@ -0,0 +1,135 @@ +package attachments + +import ( + "fmt" + "math" + "path/filepath" + "slices" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/x/ansi" +) + +const maxFilename = 15 + +type Keymap struct { + DeleteMode, + DeleteAll, + Escape key.Binding +} + +func New(renderer *Renderer, keyMap Keymap) *Attachments { + return &Attachments{ + keyMap: keyMap, + renderer: renderer, + } +} + +type Attachments struct { + renderer *Renderer + keyMap Keymap + list []message.Attachment + deleting bool +} + +func (m *Attachments) List() []message.Attachment { return m.list } +func (m *Attachments) Reset() { m.list = nil } + +func (m *Attachments) Update(msg tea.Msg) bool { + switch msg := msg.(type) { + case message.Attachment: + m.list = append(m.list, msg) + return true + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keyMap.DeleteMode): + if len(m.list) > 0 { + m.deleting = true + } + return true + case m.deleting && key.Matches(msg, m.keyMap.Escape): + m.deleting = false + return true + case m.deleting && key.Matches(msg, m.keyMap.DeleteAll): + m.deleting = false + m.list = nil + return true + case m.deleting: + // Handle digit keys for individual attachment deletion. + r := msg.Code + if r >= '0' && r <= '9' { + num := int(r - '0') + if num < len(m.list) { + m.list = slices.Delete(m.list, num, num+1) + } + m.deleting = false + } + return true + } + } + return false +} + +func (m *Attachments) Render(width int) string { + return m.renderer.Render(m.list, m.deleting, width) +} + +func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer { + return &Renderer{ + normalStyle: normalStyle, + textStyle: textStyle, + imageStyle: imageStyle, + deletingStyle: deletingStyle, + } +} + +type Renderer struct { + normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style +} + +func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string { + var chips []string + + maxItemWidth := lipgloss.Width(r.imageStyle.String() + r.normalStyle.Render(strings.Repeat("x", maxFilename))) + fits := int(math.Floor(float64(width)/float64(maxItemWidth))) - 1 + + for i, att := range attachments { + filename := filepath.Base(att.FileName) + // Truncate if needed. + if ansi.StringWidth(filename) > maxFilename { + filename = ansi.Truncate(filename, maxFilename, "…") + } + + if deleting { + chips = append( + chips, + r.deletingStyle.Render(fmt.Sprintf("%d", i)), + r.normalStyle.Render(filename), + ) + } else { + chips = append( + chips, + r.icon(att).String(), + r.normalStyle.Render(filename), + ) + } + + if i == fits && len(attachments) > i { + chips = append(chips, lipgloss.NewStyle().Width(maxItemWidth).Render(fmt.Sprintf("%d more…", len(attachments)-fits))) + break + } + } + + return lipgloss.JoinHorizontal(lipgloss.Left, chips...) +} + +func (r *Renderer) icon(a message.Attachment) lipgloss.Style { + if a.IsImage() { + return r.imageStyle + } + return r.textStyle +} diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index da55faa10c842f7ad66ad824f69564694855bb50..d2e655d57ab7549411a1fa2d23f5beb52d4f92cd 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -10,6 +10,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/attachments" "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" ) @@ -158,7 +159,13 @@ func cappedMessageWidth(availableWidth int) int { func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { switch msg.Role { case message.User: - return []MessageItem{NewUserMessageItem(sty, msg)} + r := attachments.NewRenderer( + sty.Attachments.Normal, + sty.Attachments.Deleting, + sty.Attachments.Image, + sty.Attachments.Text, + ) + return []MessageItem{NewUserMessageItem(sty, msg, r)} case message.Assistant: var items []MessageItem if ShouldRenderAssistantMessage(msg) { diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go index 17033db31b92a193573482d60256cdb6ed3efd4c..2b36c0a26896ca3fd87afc7e2826fabf21cfd4ae 100644 --- a/internal/ui/chat/user.go +++ b/internal/ui/chat/user.go @@ -1,15 +1,13 @@ package chat import ( - "fmt" - "path/filepath" "strings" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/attachments" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/styles" - "github.com/charmbracelet/x/ansi" ) // UserMessageItem represents a user message in the chat UI. @@ -18,16 +16,18 @@ type UserMessageItem struct { *cachedMessageItem *focusableMessageItem - message *message.Message - sty *styles.Styles + attachments *attachments.Renderer + message *message.Message + sty *styles.Styles } // NewUserMessageItem creates a new UserMessageItem. -func NewUserMessageItem(sty *styles.Styles, message *message.Message) MessageItem { +func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachments *attachments.Renderer) MessageItem { return &UserMessageItem{ highlightableMessageItem: defaultHighlighter(sty), cachedMessageItem: &cachedMessageItem{}, focusableMessageItem: &focusableMessageItem{}, + attachments: attachments, message: message, sty: sty, } @@ -73,46 +73,14 @@ func (m *UserMessageItem) ID() string { return m.message.ID } -// renderAttachments renders attachments with wrapping if they exceed the width. -// TODO: change the styles here so they match the new design +// renderAttachments renders attachments. func (m *UserMessageItem) renderAttachments(width int) string { - const maxFilenameWidth = 10 - - attachments := make([]string, len(m.message.BinaryContent())) - for i, attachment := range m.message.BinaryContent() { - filename := filepath.Base(attachment.Path) - attachments[i] = m.sty.Chat.Message.Attachment.Render(fmt.Sprintf( - " %s %s ", - styles.DocumentIcon, - ansi.Truncate(filename, maxFilenameWidth, "…"), - )) - } - - // Wrap attachments into lines that fit within the width. - var lines []string - var currentLine []string - currentWidth := 0 - - for _, att := range attachments { - attWidth := lipgloss.Width(att) - sepWidth := 1 - if len(currentLine) == 0 { - sepWidth = 0 - } - - if currentWidth+sepWidth+attWidth > width && len(currentLine) > 0 { - lines = append(lines, strings.Join(currentLine, " ")) - currentLine = []string{att} - currentWidth = attWidth - } else { - currentLine = append(currentLine, att) - currentWidth += sepWidth + attWidth - } - } - - if len(currentLine) > 0 { - lines = append(lines, strings.Join(currentLine, " ")) + var attachments []message.Attachment + for _, at := range m.message.BinaryContent() { + attachments = append(attachments, message.Attachment{ + FileName: at.Path, + MimeType: at.MIMEType, + }) } - - return strings.Join(lines, "\n") + return m.attachments.Render(attachments, false, width) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index c06ea6f7ada0180c3a8ffbbeff92430a9c0ff641..aeb669e3208a19e5cd390baab8714d6f293e1fa6 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -21,13 +21,14 @@ import ( "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/filetracker" "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/dialogs/filepicker" "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/attachments" "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/completions" @@ -40,6 +41,12 @@ import ( "github.com/charmbracelet/ultraviolet/screen" ) +// Max file size set to 5M. +const maxAttachmentSize = int64(5 * 1024 * 1024) + +// Allowed image formats. +var allowedImageTypes = []string{".jpg", ".jpeg", ".png"} + // uiFocusState represents the current focus state of the UI. type uiFocusState uint8 @@ -99,7 +106,8 @@ type UI struct { // Editor components textarea textarea.Model - attachments []message.Attachment // TODO: Implement attachments + // Attachment list + attachments *attachments.Attachments readyPlaceholder string workingPlaceholder string @@ -141,6 +149,8 @@ func New(com *common.Common) *UI { ch := NewChat(com) + keyMap := DefaultKeyMap() + // Completions component comp := completions.New( com.Styles.Completions.Normal, @@ -148,15 +158,31 @@ func New(com *common.Common) *UI { com.Styles.Completions.Match, ) + // Attachments component + attachments := attachments.New( + attachments.NewRenderer( + com.Styles.Attachments.Normal, + com.Styles.Attachments.Deleting, + com.Styles.Attachments.Image, + com.Styles.Attachments.Text, + ), + attachments.Keymap{ + DeleteMode: keyMap.Editor.AttachmentDeleteMode, + DeleteAll: keyMap.Editor.DeleteAllAttachments, + Escape: keyMap.Editor.Escape, + }, + ) + ui := &UI{ com: com, dialog: dialog.NewOverlay(), - keyMap: DefaultKeyMap(), + keyMap: keyMap, focus: uiFocusNone, state: uiConfigure, textarea: ta, chat: ch, completions: comp, + attachments: attachments, } status := NewStatus(com, ui) @@ -393,6 +419,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // at this point this can only handle [message.Attachment] message, and we + // should return all cmds anyway. + _ = m.attachments.Update(msg) return m, tea.Batch(cmds...) } @@ -707,6 +736,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { // Generic dialog messages case dialog.CloseMsg: m.dialog.CloseFrontDialog() + if m.focus == uiFocusEditor { + cmds = append(cmds, m.textarea.Focus()) + } // Session dialog messages case dialog.SessionSelectedMsg: @@ -803,7 +835,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { case completions.SelectionMsg: // Handle file completion selection. if item, ok := msg.Value.(completions.FileCompletionValue); ok { - m.insertFileCompletion(item.Path) + cmds = append(cmds, m.insertFileCompletion(item.Path)) } if !msg.Insert { m.closeCompletions() @@ -815,6 +847,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } } + if ok := m.attachments.Update(msg); ok { + return tea.Batch(cmds...) + } + switch { case key.Matches(msg, m.keyMap.Editor.SendMessage): value := m.textarea.Value() @@ -832,8 +868,8 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { return m.openQuitDialog() } - attachments := m.attachments - m.attachments = nil + attachments := m.attachments.List() + m.attachments.Reset() if len(value) == 0 { return nil } @@ -1033,7 +1069,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { main := uv.NewStyledString(m.landingView()) main.Draw(scr, layout.main) - editor := uv.NewStyledString(m.textarea.View()) + editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx())) editor.Draw(scr, layout.editor) case uiChat: @@ -1043,7 +1079,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { header.Draw(scr, layout.header) m.drawSidebar(scr, layout.sidebar) - editor := uv.NewStyledString(m.textarea.View()) + editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx() - layout.sidebar.Dx())) editor.Draw(scr, layout.editor) case uiChatCompact: @@ -1057,7 +1093,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { main := uv.NewStyledString(mainView) main.Draw(scr, layout.main) - editor := uv.NewStyledString(m.textarea.View()) + editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx())) editor.Draw(scr, layout.editor) } @@ -1128,6 +1164,10 @@ func (m *UI) Cursor() *tea.Cursor { cur := m.textarea.Cursor() cur.X++ // Adjust for app margins cur.Y += m.layout.editor.Min.Y + // Offset for attachment row if present. + if len(m.attachments.List()) > 0 { + cur.Y++ + } return cur } } @@ -1229,7 +1269,7 @@ func (m *UI) FullHelp() [][]key.Binding { k := &m.keyMap help := k.Help help.SetHelp("ctrl+g", "less") - hasAttachments := false // TODO: implement attachments + hasAttachments := len(m.attachments.List()) > 0 hasSession := m.session != nil && m.session.ID != "" commands := k.Commands if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 { @@ -1317,6 +1357,17 @@ func (m *UI) FullHelp() [][]key.Binding { k.Editor.MentionFile, k.Editor.OpenEditor, }, + ) + if hasAttachments { + binds = append(binds, + []key.Binding{ + k.Editor.AttachmentDeleteMode, + k.Editor.DeleteAllAttachments, + k.Editor.Escape, + }, + ) + } + binds = append(binds, []key.Binding{ help, }, @@ -1601,39 +1652,48 @@ func (m *UI) closeCompletions() { // 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) { +func (m *UI) insertFileCompletion(path string) tea.Cmd { value := m.textarea.Value() word := m.textareaWord() // Find the @ and query to replace. if m.completionsStartIndex > len(value) { - return + return nil } // Build the new value: everything before @, the path, everything after query. - endIdx := m.completionsStartIndex + len(word) - if endIdx > len(value) { - endIdx = len(value) - } + endIdx := min(m.completionsStartIndex+len(word), len(value)) newValue := value[:m.completionsStartIndex] + path + value[endIdx:] m.textarea.SetValue(newValue) - // XXX: This will always move the cursor to the end of the textarea. m.textarea.MoveToEnd() + m.textarea.InsertRune(' ') + + return func() tea.Msg { + absPath, _ := filepath.Abs(path) + // Skip attachment if file was already read and hasn't been modified. + lastRead := filetracker.LastReadTime(absPath) + if !lastRead.IsZero() { + if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) { + return nil + } + } - // Add file as attachment. - content, err := os.ReadFile(path) - if err != nil { - // If it fails, let the LLM handle it later. - return - } + // Add file as attachment. + content, err := os.ReadFile(path) + if err != nil { + // If it fails, let the LLM handle it later. + return nil + } + filetracker.RecordRead(absPath) - m.attachments = append(m.attachments, message.Attachment{ - FilePath: path, - FileName: filepath.Base(path), - MimeType: mimeOf(content), - Content: content, - }) + return message.Attachment{ + FilePath: path, + FileName: filepath.Base(path), + MimeType: mimeOf(content), + Content: content, + } + } } // completionsPosition returns the X and Y position for the completions popup. @@ -1690,6 +1750,18 @@ func (m *UI) randomizePlaceholders() { m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] } +// renderEditorView renders the editor view with attachments if any. +func (m *UI) renderEditorView(width int) string { + if len(m.attachments.List()) == 0 { + return m.textarea.View() + } + return lipgloss.JoinVertical( + lipgloss.Top, + m.attachments.Render(width), + m.textarea.View(), + ) +} + // renderHeader renders and caches the header logo at the specified width. func (m *UI) renderHeader(compact bool, width int) { // TODO: handle the compact case differently @@ -1847,15 +1919,18 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { var cmd tea.Cmd path := strings.ReplaceAll(msg.Content, "\\ ", " ") - // try to get an image + // Try to get an image. path, err := filepath.Abs(strings.TrimSpace(path)) if err != nil { m.textarea, cmd = m.textarea.Update(msg) return cmd } + + // Check if file has an allowed image extension. isAllowedType := false - for _, ext := range filepicker.AllowedTypes { - if strings.HasSuffix(path, ext) { + lowerPath := strings.ToLower(path) + for _, ext := range allowedImageTypes { + if strings.HasSuffix(lowerPath, ext) { isAllowedType = true break } @@ -1864,24 +1939,31 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { m.textarea, cmd = m.textarea.Update(msg) return cmd } - tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize) - if tooBig { - m.textarea, cmd = m.textarea.Update(msg) - return cmd - } - content, err := os.ReadFile(path) - if err != nil { - m.textarea, cmd = m.textarea.Update(msg) - return cmd + return func() tea.Msg { + fileInfo, err := os.Stat(path) + if err != nil { + return uiutil.ReportError(err) + } + if fileInfo.Size() > maxAttachmentSize { + return uiutil.ReportWarn("File is too big (>5mb)") + } + + content, err := os.ReadFile(path) + if err != nil { + return uiutil.ReportError(err) + } + + mimeBufferSize := min(512, len(content)) + mimeType := http.DetectContentType(content[:mimeBufferSize]) + fileName := filepath.Base(path) + return message.Attachment{ + FilePath: path, + FileName: fileName, + MimeType: mimeType, + Content: content, + } } - 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 uiutil.CmdHandler(filepicker.FilePickedMsg{ - Attachment: attachment, - }) } // renderLogo renders the Crush logo with the given styles and dimensions. diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 0f0de03cdba36056e82910a5423aac56a80fbb04..06c790ba4e2fd37352aa79d7b2acfeccd806e8f9 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -16,15 +16,14 @@ import ( ) const ( - CheckIcon string = "✓" - ErrorIcon string = "×" - WarningIcon string = "⚠" - InfoIcon string = "ⓘ" - HintIcon string = "∵" - SpinnerIcon string = "..." - LoadingIcon string = "⟳" - DocumentIcon string = "🖼" - ModelIcon string = "◇" + CheckIcon string = "✓" + ErrorIcon string = "×" + WarningIcon string = "⚠" + InfoIcon string = "ⓘ" + HintIcon string = "∵" + SpinnerIcon string = "..." + LoadingIcon string = "⟳" + ModelIcon string = "◇" ArrowRightIcon string = "→" @@ -43,6 +42,9 @@ const ( TodoCompletedIcon string = "✓" TodoPendingIcon string = "•" TodoInProgressIcon string = "→" + + ImageIcon = "■" + TextIcon = "≡" ) const ( @@ -208,7 +210,6 @@ type Styles struct { ErrorTag lipgloss.Style ErrorTitle lipgloss.Style ErrorDetails lipgloss.Style - Attachment lipgloss.Style ToolCallFocused lipgloss.Style ToolCallCompact lipgloss.Style ToolCallBlurred lipgloss.Style @@ -337,6 +338,14 @@ type Styles struct { Focused lipgloss.Style Match lipgloss.Style } + + // Attachments styles + Attachments struct { + Normal lipgloss.Style + Image lipgloss.Style + Text lipgloss.Style + Deleting lipgloss.Style + } } // ChromaTheme converts the current markdown chroma styles to a chroma @@ -1119,7 +1128,6 @@ func DefaultStyles() Styles { s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle) // Message item styles - s.Chat.Message.Attachment = lipgloss.NewStyle().MarginLeft(1).Background(bgSubtle) s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1). BorderStyle(messageFocussedBorder). BorderLeft(true). @@ -1172,6 +1180,13 @@ func DefaultStyles() Styles { s.Completions.Focused = base.Background(primary).Foreground(white) s.Completions.Match = base.Underline(true) + // Attachments styles + attachmentIconStyle := base.Foreground(bgSubtle).Background(green).Padding(0, 1) + s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon) + s.Attachments.Text = attachmentIconStyle.SetString(TextIcon) + s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(fgMuted).Foreground(fgBase) + s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(red).Foreground(fgBase) + return s } From 4c540cfccb48ce16b5b4fa53c11e48bc238577b9 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 8 Jan 2026 14:46:45 -0300 Subject: [PATCH 125/335] chore: fix const type Signed-off-by: Carlos Alexandro Becker --- internal/ui/styles/styles.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 06c790ba4e2fd37352aa79d7b2acfeccd806e8f9..598ca2d0f0216c1d04ac99424e30cc07b56b39a0 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -43,8 +43,8 @@ const ( TodoPendingIcon string = "•" TodoInProgressIcon string = "→" - ImageIcon = "■" - TextIcon = "≡" + ImageIcon string = "■" + TextIcon string = "≡" ) const ( From fa5c593c61775ffb3d14f02b06f784c696cea303 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 8 Jan 2026 15:01:12 -0500 Subject: [PATCH 126/335] fix(ui): dry up session dialog key bindings --- internal/ui/dialog/sessions.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 13306cc87945d1dafe2240c08d04a2200d7c83b9..a60e900e12e5fc8578c077f0bc63257619cccc57 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -27,6 +27,7 @@ type Session struct { Select key.Binding Next key.Binding Previous key.Binding + UpDown key.Binding Close key.Binding } } @@ -74,6 +75,10 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) key.WithKeys("up", "ctrl+p"), key.WithHelp("↑", "previous item"), ) + s.keyMap.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑↓", "choose"), + ) s.keyMap.Close = CloseKey return s, nil @@ -167,12 +172,8 @@ func (s *Session) View() string { // ShortHelp implements [help.KeyMap]. func (s *Session) ShortHelp() []key.Binding { - updown := key.NewBinding( - key.WithKeys("down", "up"), - key.WithHelp("↑↓", "choose"), - ) return []key.Binding{ - updown, + s.keyMap.UpDown, s.keyMap.Select, s.keyMap.Close, } From 7c99274dd8a8cbbd0afadbadfc0ae550854a9c23 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jan 2026 08:38:59 -0300 Subject: [PATCH 127/335] feat: open editor in the right position (#1803) Signed-off-by: Carlos Alexandro Becker --- go.mod | 3 ++- go.sum | 6 ++++-- internal/ui/model/ui.go | 26 +++++++++++++------------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 977a0333286b583d81f5bdf700a4d0e9624323fc..0cbe6e762ea491e6d5271a95ecb4e9e4c31e5a95 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/charmbracelet/crush go 1.25.5 require ( - charm.land/bubbles/v2 v2.0.0-rc.1 + 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/fantasy v0.6.0 charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1 @@ -23,6 +23,7 @@ require ( github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 github.com/charmbracelet/x/ansi v0.11.3 + 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-20250708181618-a60a724ba6c3 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f diff --git a/go.sum b/go.sum index 29ac00482dedaeb5f7444810b7194a367c6f67a9..3131993a7dd1473522938031ebb2be23b906e061 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM= -charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4= +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/fantasy v0.6.0 h1:0PZfZ/w6c70UdlumGGFW6s9zTV6f4xAV/bXo6vGuZsc= @@ -104,6 +104,8 @@ github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 h1:j3PW2 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560/go.mod h1:VWATWLRwYP06VYCEur7FsNR2B1xAo7Y+xl1PTbd1ePc= github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI= github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI= +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= github.com/charmbracelet/x/etag v0.2.0/go.mod h1:C1B7/bsgvzzxpfu0Rabbd+rTHJa5TmC/qgTseCf6DF0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50= diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index aeb669e3208a19e5cd390baab8714d6f293e1fa6..e3ea96b2f35eff10874bdc6b92dae410622ff4cf 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -9,7 +9,6 @@ import ( "net/http" "os" "path/filepath" - "runtime" "slices" "strings" @@ -39,6 +38,7 @@ import ( "github.com/charmbracelet/crush/internal/version" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/ultraviolet/screen" + "github.com/charmbracelet/x/editor" ) // Max file size set to 5M. @@ -1562,16 +1562,6 @@ type layout struct { } func (m *UI) openEditor(value string) tea.Cmd { - editor := os.Getenv("EDITOR") - if editor == "" { - // Use platform-appropriate default editor - if runtime.GOOS == "windows" { - editor = "notepad" - } else { - editor = "nvim" - } - } - tmpfile, err := os.CreateTemp("", "msg_*.md") if err != nil { return uiutil.ReportError(err) @@ -1580,8 +1570,18 @@ func (m *UI) openEditor(value string) tea.Cmd { if _, err := tmpfile.WriteString(value); err != nil { return uiutil.ReportError(err) } - cmdStr := editor + " " + tmpfile.Name() - return uiutil.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg { + cmd, err := editor.Command( + "crush", + tmpfile.Name(), + editor.AtPosition( + m.textarea.Line()+1, + m.textarea.Column()+1, + ), + ) + if err != nil { + return uiutil.ReportError(err) + } + return tea.ExecProcess(cmd, func(err error) tea.Msg { if err != nil { return uiutil.ReportError(err) } From 1e83e6657d52c1a7746a6b9425861de1cf83ff28 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jan 2026 08:44:29 -0300 Subject: [PATCH 128/335] feat: paste as file (#1800) Signed-off-by: Carlos Alexandro Becker --- internal/ui/model/ui.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index e3ea96b2f35eff10874bdc6b92dae410622ff4cf..98685e0a2128e938d1439e2ab78b2a84398cab6d 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -9,7 +9,9 @@ import ( "net/http" "os" "path/filepath" + "regexp" "slices" + "strconv" "strings" "charm.land/bubbles/v2/help" @@ -1917,6 +1919,25 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { return nil } + // If pasted text has more than 2 newlines, treat it as a file attachment. + if strings.Count(msg.Content, "\n") > 2 { + return func() tea.Msg { + content := []byte(msg.Content) + if int64(len(content)) > maxAttachmentSize { + return uiutil.ReportWarn("Paste is too big (>5mb)") + } + name := fmt.Sprintf("paste_%d.txt", m.pasteIdx()) + mimeBufferSize := min(512, len(content)) + mimeType := http.DetectContentType(content[:mimeBufferSize]) + return message.Attachment{ + FileName: name, + FilePath: name, + MimeType: mimeType, + Content: content, + } + } + } + var cmd tea.Cmd path := strings.ReplaceAll(msg.Content, "\\ ", " ") // Try to get an image. @@ -1966,6 +1987,23 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { } } +var pasteRE = regexp.MustCompile(`paste_(\d+).txt`) + +func (m *UI) pasteIdx() int { + result := 0 + for _, at := range m.attachments.List() { + 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 +} + // 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{ From 030c8fc58ea90cdf796827a8eb69b1e9f02cb5c6 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jan 2026 14:04:45 -0300 Subject: [PATCH 129/335] feat: allow to send the prompt if its empty but has text attachments (#1809) Signed-off-by: Carlos Alexandro Becker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/agent/agent.go | 2 +- internal/message/attachment.go | 12 +++++++++++- internal/ui/chat/user.go | 6 +++++- internal/ui/model/ui.go | 2 +- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 759a9274f2f4cc8c306ac0cc042de89cd1a25097..7c7ac4c6c1f3d320fe3e3dd865f8e7b56c73010d 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -134,7 +134,7 @@ func NewSessionAgent( } func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) { - if call.Prompt == "" { + if call.Prompt == "" && !message.ContainsTextAttachment(call.Attachments) { return nil, ErrEmptyPrompt } if call.SessionID == "" { diff --git a/internal/message/attachment.go b/internal/message/attachment.go index 0e3b70a8766c74d37399c1ba8c38fe19e74f871d..c3c04aaea237e9ad060a8687c123a82643edba24 100644 --- a/internal/message/attachment.go +++ b/internal/message/attachment.go @@ -1,6 +1,9 @@ package message -import "strings" +import ( + "slices" + "strings" +) type Attachment struct { FilePath string @@ -11,3 +14,10 @@ type Attachment struct { func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") } func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") } + +// ContainsTextAttachment returns true if any of the attachments is a text attachment. +func ContainsTextAttachment(attachments []Attachment) bool { + return slices.ContainsFunc(attachments, func(a Attachment) bool { + return a.IsText() + }) +} diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go index 2b36c0a26896ca3fd87afc7e2826fabf21cfd4ae..5eb452b1fbc396f3c603af89dea9de000502fb94 100644 --- a/internal/ui/chat/user.go +++ b/internal/ui/chat/user.go @@ -60,7 +60,11 @@ func (m *UserMessageItem) Render(width int) string { if len(m.message.BinaryContent()) > 0 { attachmentsStr := m.renderAttachments(cappedWidth) - content = strings.Join([]string{content, "", attachmentsStr}, "\n") + if content == "" { + content = attachmentsStr + } else { + content = strings.Join([]string{content, "", attachmentsStr}, "\n") + } } height = lipgloss.Height(content) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 98685e0a2128e938d1439e2ab78b2a84398cab6d..d666cffc796ff309e75be5fc403bea01c4b049b5 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -872,7 +872,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { attachments := m.attachments.List() m.attachments.Reset() - if len(value) == 0 { + if len(value) == 0 && !message.ContainsTextAttachment(attachments) { return nil } From c4add9e0fa401da01b8d0ea6d1b42d03bc354de1 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jan 2026 17:59:05 -0300 Subject: [PATCH 130/335] fix: use cached lsp.DiagnosticCounts (#1814) Signed-off-by: Carlos Alexandro Becker --- internal/ui/model/lsp.go | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go index b1a3b8ebb223ce20687a0885f21a65a7ed1bf88a..1f13b5afc3c8a90b6ca14e304636e31fbedddbfc 100644 --- a/internal/ui/model/lsp.go +++ b/internal/ui/model/lsp.go @@ -29,19 +29,12 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string { if !ok { continue } + counts := client.GetDiagnosticCounts() lspErrs := map[protocol.DiagnosticSeverity]int{ - protocol.SeverityError: 0, - protocol.SeverityWarning: 0, - protocol.SeverityHint: 0, - protocol.SeverityInformation: 0, - } - - for _, diagnostics := range client.GetDiagnostics() { - for _, diagnostic := range diagnostics { - if severity, ok := lspErrs[diagnostic.Severity]; ok { - lspErrs[diagnostic.Severity] = severity + 1 - } - } + protocol.SeverityError: counts.Error, + protocol.SeverityWarning: counts.Warning, + protocol.SeverityHint: counts.Hint, + protocol.SeverityInformation: counts.Information, } lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs}) From fcd1b83168efee74b3cf34f0b33c0a5f2a2bbf2f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 12 Jan 2026 05:13:25 -0500 Subject: [PATCH 131/335] refactor(ui): dialog: message and draw handling (#1822) --- internal/ui/dialog/actions.go | 48 ++++++ internal/ui/dialog/commands.go | 93 +++++----- internal/ui/dialog/dialog.go | 47 +++-- internal/ui/dialog/messages.go | 44 ----- internal/ui/dialog/models.go | 59 +++---- internal/ui/dialog/quit.go | 27 +-- internal/ui/dialog/sessions.go | 67 +++---- internal/ui/dialog/sessions_item.go | 10 +- internal/ui/model/ui.go | 259 +++++++++++++++------------- 9 files changed, 348 insertions(+), 306 deletions(-) create mode 100644 internal/ui/dialog/actions.go delete mode 100644 internal/ui/dialog/messages.go diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go new file mode 100644 index 0000000000000000000000000000000000000000..a152a125b179e7f14fd69361f236ce9d76e8effa --- /dev/null +++ b/internal/ui/dialog/actions.go @@ -0,0 +1,48 @@ +package dialog + +import ( + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/session" +) + +// ActionClose is a message to close the current dialog. +type ActionClose struct{} + +// ActionQuit is a message to quit the application. +type ActionQuit = tea.QuitMsg + +// ActionOpenDialog is a message to open a dialog. +type ActionOpenDialog struct { + DialogID string +} + +// ActionSelectSession is a message indicating a session has been selected. +type ActionSelectSession struct { + Session session.Session +} + +// ActionSelectModel is a message indicating a model has been selected. +type ActionSelectModel struct { + Model config.SelectedModel + ModelType config.SelectedModelType +} + +// Messages for commands +type ( + ActionNewSession struct{} + ActionToggleHelp struct{} + ActionToggleCompactMode struct{} + ActionToggleThinking struct{} + ActionExternalEditor struct{} + ActionToggleYoloMode struct{} + ActionSummarize struct { + SessionID string + } +) + +// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the +// Bubble Tea program loop. +type ActionCmd struct { + Cmd tea.Cmd +} diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index da95a252c044378690f3cf3f8cfbdde8a124349b..ec62320909445f96e3063747f6fea3d77628e6e4 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -21,6 +21,8 @@ import ( "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/uicmd" "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" ) // CommandsID is the identifier for the commands dialog. @@ -43,10 +45,11 @@ type Commands struct { userCmds []uicmd.Command mcpPrompts *csync.Slice[uicmd.Command] - help help.Model - input textinput.Model - list *list.FilterableList - width, height int + help help.Model + input textinput.Model + list *list.FilterableList + + width int } var _ Dialog = (*Commands)(nil) @@ -114,33 +117,18 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) { return c, nil } -// SetSize sets the size of the dialog. -func (c *Commands) SetSize(width, height int) { - t := c.com.Styles - c.width = width - c.height = height - innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize() - heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content - t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content - t.Dialog.HelpView.GetVerticalFrameSize() + - t.Dialog.View.GetVerticalFrameSize() - c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding - c.list.SetSize(innerWidth, height-heightOffset) - c.help.SetWidth(width) -} - // ID implements Dialog. func (c *Commands) ID() string { return CommandsID } -// Update implements Dialog. -func (c *Commands) Update(msg tea.Msg) tea.Msg { +// HandleMsg implements Dialog. +func (c *Commands) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, c.keyMap.Close): - return CloseMsg{} + return ActionClose{} case key.Matches(msg, c.keyMap.Previous): c.list.Focus() if c.list.IsSelectedFirst() { @@ -181,9 +169,7 @@ func (c *Commands) Update(msg tea.Msg) tea.Msg { c.list.SetFilter(value) c.list.ScrollToTop() c.list.SetSelected(0) - if cmd != nil { - return cmd() - } + return ActionCmd{cmd} } } return nil @@ -231,16 +217,35 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm return strings.Join(parts, " ") } -// View implements [Dialog]. -func (c *Commands) View() string { +// Draw implements [Dialog]. +func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := c.com.Styles + width := max(0, min(100, area.Dx())) + height := max(0, min(30, area.Dy())) + c.width = width + // TODO: Why do we need this 2? + innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2 + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content + t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content + t.Dialog.HelpView.GetVerticalFrameSize() + + // TODO: Why do we need this 2? + t.Dialog.View.GetVerticalFrameSize() + 2 + c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + c.list.SetSize(innerWidth, height-heightOffset) + c.help.SetWidth(innerWidth) + radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0) titleStyle := t.Dialog.Title - dialogStyle := t.Dialog.View.Width(c.width) + dialogStyle := t.Dialog.View.Width(width) headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() - header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio - return HeaderInputListHelpView(t, c.width, c.list.Height(), header, - c.input.View(), c.list.Render(), c.help.View(c)) + helpView := ansi.Truncate(c.help.View(c), innerWidth, "") + header := common.DialogTitle(t, "Commands", width-headerOffset) + radio + view := HeaderInputListHelpView(t, width, c.list.Height(), header, + c.input.View(), c.list.Render(), helpView) + + cur := c.Cursor() + DrawCenterCursor(scr, area, view, cur) + return cur } // ShortHelp implements [help.KeyMap]. @@ -318,7 +323,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Description: "start a new session", Shortcut: "ctrl+n", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(NewSessionsMsg{}) + return uiutil.CmdHandler(ActionNewSession{}) }, }, { @@ -327,7 +332,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Description: "Switch to a different session", Shortcut: "ctrl+s", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(OpenDialogMsg{SessionsID}) + return uiutil.CmdHandler(ActionOpenDialog{SessionsID}) }, }, { @@ -337,7 +342,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { // FIXME: The shortcut might get updated if enhanced keyboard is supported. Shortcut: "ctrl+l", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(OpenDialogMsg{ModelsID}) + return uiutil.CmdHandler(ActionOpenDialog{ModelsID}) }, }, } @@ -349,7 +354,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Title: "Summarize Session", Description: "Summarize the current session and create a new one with the summary", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(CompactMsg{ + return uiutil.CmdHandler(ActionSummarize{ SessionID: c.sessionID, }) }, @@ -375,7 +380,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Title: status + " Thinking Mode", Description: "Toggle model thinking for reasoning-capable models", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ToggleThinkingMsg{}) + return uiutil.CmdHandler(ActionToggleThinking{}) }, }) } @@ -387,7 +392,9 @@ func (c *Commands) defaultCommands() []uicmd.Command { Title: "Select Reasoning Effort", Description: "Choose reasoning effort level (low/medium/high)", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(OpenReasoningDialogMsg{}) + return uiutil.CmdHandler(ActionOpenDialog{ + // TODO: Pass reasoning dialog id + }) }, }) } @@ -401,7 +408,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Title: "Toggle Sidebar", Description: "Toggle between compact and normal layout", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ToggleCompactModeMsg{}) + return uiutil.CmdHandler(ActionToggleCompactMode{}) }, }) } @@ -416,7 +423,9 @@ func (c *Commands) defaultCommands() []uicmd.Command { Shortcut: "ctrl+f", Description: "Open file picker", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(OpenFilePickerMsg{}) + return uiutil.CmdHandler(ActionOpenDialog{ + // TODO: Pass file picker dialog id + }) }, }) } @@ -431,7 +440,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Shortcut: "ctrl+o", Description: "Open external editor to compose message", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(OpenExternalEditorMsg{}) + return uiutil.CmdHandler(ActionExternalEditor{}) }, }) } @@ -442,7 +451,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Title: "Toggle Yolo Mode", Description: "Toggle yolo mode", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ToggleYoloModeMsg{}) + return uiutil.CmdHandler(ActionToggleYoloMode{}) }, }, { @@ -451,7 +460,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Shortcut: "ctrl+g", Description: "Toggle help", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ToggleHelpMsg{}) + return uiutil.CmdHandler(ActionToggleHelp{}) }, }, { diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 2796ea16a78b24e0349ac30f1a4485271deae51e..f51ff0a4ee8390c8b889ccb1f3f3c2ba60c39532 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -14,11 +14,19 @@ var CloseKey = key.NewBinding( key.WithHelp("esc", "exit"), ) +// Action represents an action taken in a dialog after handling a message. +type Action interface{} + // Dialog is a component that can be displayed on top of the UI. type Dialog interface { + // ID returns the unique identifier of the dialog. ID() string - Update(msg tea.Msg) tea.Msg - View() string + // HandleMsg processes a message and returns an action. An [Action] can be + // anything and the caller is responsible for handling it appropriately. + HandleMsg(msg tea.Msg) Action + // Draw draws the dialog onto the provided screen within the specified area + // and returns the desired cursor position on the screen. + Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor } // Overlay manages multiple dialogs as an overlay. @@ -113,33 +121,34 @@ func (d *Overlay) Update(msg tea.Msg) tea.Msg { return nil } - return dialog.Update(msg) + return dialog.HandleMsg(msg) } -// CenterPosition calculates the centered position for the dialog. -func (d *Overlay) CenterPosition(area uv.Rectangle, dialogID string) uv.Rectangle { - dialog := d.Dialog(dialogID) - if dialog == nil { - return uv.Rectangle{} +// DrawCenterCursor draws the given string view centered in the screen area and +// adjusts the cursor position accordingly. +func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) { + width, height := lipgloss.Size(view) + center := common.CenterRect(area, width, height) + if cur != nil { + cur.X += center.Min.X + cur.Y += center.Min.Y } - return d.centerPositionView(area, dialog.View()) + + uv.NewStyledString(view).Draw(scr, center) } -func (d *Overlay) centerPositionView(area uv.Rectangle, view string) uv.Rectangle { - viewWidth := lipgloss.Width(view) - viewHeight := lipgloss.Height(view) - return common.CenterRect(area, viewWidth, viewHeight) +// DrawCenter draws the given string view centered in the screen area. +func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) { + DrawCenterCursor(scr, area, view, nil) } // Draw renders the overlay and its dialogs. -func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) { +func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + var cur *tea.Cursor for _, dialog := range d.dialogs { - view := dialog.View() - center := d.centerPositionView(area, view) - if area.Overlaps(center) { - uv.NewStyledString(view).Draw(scr, center) - } + cur = dialog.Draw(scr, area) } + return cur } // removeDialog removes a dialog from the stack. diff --git a/internal/ui/dialog/messages.go b/internal/ui/dialog/messages.go deleted file mode 100644 index 8efc59240e83ea8137cdaf14a7c87f903b8683b5..0000000000000000000000000000000000000000 --- a/internal/ui/dialog/messages.go +++ /dev/null @@ -1,44 +0,0 @@ -package dialog - -import ( - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/session" -) - -// CloseMsg is a message to close the current dialog. -type CloseMsg struct{} - -// QuitMsg is a message to quit the application. -type QuitMsg = tea.QuitMsg - -// OpenDialogMsg is a message to open a dialog. -type OpenDialogMsg struct { - DialogID string -} - -// SessionSelectedMsg is a message indicating a session has been selected. -type SessionSelectedMsg struct { - Session session.Session -} - -// ModelSelectedMsg is a message indicating a model has been selected. -type ModelSelectedMsg struct { - Model config.SelectedModel - ModelType config.SelectedModelType -} - -// Messages for commands -type ( - NewSessionsMsg struct{} - OpenFilePickerMsg struct{} - ToggleHelpMsg struct{} - ToggleCompactModeMsg struct{} - ToggleThinkingMsg struct{} - OpenReasoningDialogMsg struct{} - OpenExternalEditorMsg struct{} - ToggleYoloModeMsg struct{} - CompactMsg struct { - SessionID string - } -) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 1830a0880975b136446ac5bb45dae0d558fc2795..344db9cd3a2dbf96f73465d66449d2f60a9df7c3 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -15,6 +15,8 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" ) // ModelType represents the type of model to select. @@ -76,8 +78,6 @@ type Models struct { modelType ModelType providers []catwalk.Provider - width, height int - keyMap struct { Tab key.Binding UpDown key.Binding @@ -147,33 +147,18 @@ func NewModels(com *common.Common) (*Models, error) { return m, nil } -// SetSize sets the size of the dialog. -func (m *Models) SetSize(width, height int) { - t := m.com.Styles - m.width = width - m.height = height - innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content - t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content - t.Dialog.HelpView.GetVerticalFrameSize() + - t.Dialog.View.GetVerticalFrameSize() - m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding - m.list.SetSize(innerWidth, height-heightOffset) - m.help.SetWidth(width) -} - // ID implements Dialog. func (m *Models) ID() string { return ModelsID } -// Update implements Dialog. -func (m *Models) Update(msg tea.Msg) tea.Msg { +// HandleMsg implements Dialog. +func (m *Models) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, m.keyMap.Close): - return CloseMsg{} + return ActionClose{} case key.Matches(msg, m.keyMap.Previous): m.list.Focus() if m.list.IsSelectedFirst() { @@ -203,7 +188,7 @@ func (m *Models) Update(msg tea.Msg) tea.Msg { break } - return ModelSelectedMsg{ + return ActionSelectModel{ Model: modelItem.SelectedModel(), ModelType: modelItem.SelectedModelType(), } @@ -222,9 +207,7 @@ func (m *Models) Update(msg tea.Msg) tea.Msg { value := m.input.Value() m.list.SetFilter(value) m.list.ScrollToSelected() - if cmd != nil { - return cmd() - } + return ActionCmd{cmd} } } return nil @@ -255,9 +238,22 @@ func (m *Models) modelTypeRadioView() string { smallRadio, textStyle.Render(ModelTypeSmall.String())) } -// View implements Dialog. -func (m *Models) View() string { +// Draw implements [Dialog]. +func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := m.com.Styles + width := max(0, min(60, area.Dx())) + height := max(0, min(30, area.Dy())) + // TODO: Why do we need this 2? + innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2 + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content + t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content + t.Dialog.HelpView.GetVerticalFrameSize() + + // TODO: Why do we need this 2? + t.Dialog.View.GetVerticalFrameSize() + 2 + m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + m.list.SetSize(innerWidth, height-heightOffset) + m.help.SetWidth(innerWidth) + titleStyle := t.Dialog.Title dialogStyle := t.Dialog.View @@ -266,10 +262,15 @@ func (m *Models) View() string { headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() - header := common.DialogTitle(t, "Switch Model", m.width-headerOffset) + radios + header := common.DialogTitle(t, "Switch Model", width-headerOffset) + radios + + helpView := ansi.Truncate(m.help.View(m), innerWidth, "") + view := HeaderInputListHelpView(t, width, m.list.Height(), header, + m.input.View(), m.list.Render(), helpView) - return HeaderInputListHelpView(t, m.width, m.list.Height(), header, - m.input.View(), m.list.Render(), m.help.View(m)) + cur := m.Cursor() + DrawCenterCursor(scr, area, view, cur) + return cur } // ShortHelp returns the short help view. diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go index 21ed6a5128f6fee85a2a3216ea303fa8d843258a..11173f0eaddb35a0b96aad6b1bf957ec86a37044 100644 --- a/internal/ui/dialog/quit.go +++ b/internal/ui/dialog/quit.go @@ -5,6 +5,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/ui/common" + uv "github.com/charmbracelet/ultraviolet" ) // QuitID is the identifier for the quit dialog. @@ -25,6 +26,8 @@ type Quit struct { } } +var _ Dialog = (*Quit)(nil) + // NewQuit creates a new quit confirmation dialog. func NewQuit(com *common.Common) *Quit { q := &Quit{ @@ -64,34 +67,34 @@ func (*Quit) ID() string { return QuitID } -// Update implements [Model]. -func (q *Quit) Update(msg tea.Msg) tea.Msg { +// HandleMsg implements [Model]. +func (q *Quit) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, q.keyMap.Quit): - return QuitMsg{} + return ActionQuit{} case key.Matches(msg, q.keyMap.Close): - return CloseMsg{} + return ActionClose{} case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab): q.selectedNo = !q.selectedNo case key.Matches(msg, q.keyMap.EnterSpace): if !q.selectedNo { - return QuitMsg{} + return ActionQuit{} } - return CloseMsg{} + return ActionClose{} case key.Matches(msg, q.keyMap.Yes): - return QuitMsg{} + return ActionQuit{} case key.Matches(msg, q.keyMap.No, q.keyMap.Close): - return CloseMsg{} + return ActionClose{} } } return nil } -// View implements [Dialog]. -func (q *Quit) View() string { +// Draw implements [Dialog]. +func (q *Quit) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { const question = "Are you sure you want to quit?" baseStyle := q.com.Styles.Base buttonOpts := []common.ButtonOpts{ @@ -108,7 +111,9 @@ func (q *Quit) View() string { ), ) - return q.com.Styles.BorderFocus.Render(content) + view := q.com.Styles.BorderFocus.Render(content) + DrawCenter(scr, area, view) + return nil } // ShortHelp implements [help.KeyMap]. diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index a60e900e12e5fc8578c077f0bc63257619cccc57..3773eba6c612d824c57f1886cd017a203bdca9d5 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -9,6 +9,8 @@ import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" ) // SessionsID is the identifier for the session selector dialog. @@ -16,7 +18,6 @@ const SessionsID = "session" // Session is a session selector dialog. type Session struct { - width, height int com *common.Common help help.Model list *list.FilterableList @@ -56,6 +57,8 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) s.help = help s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...) s.list.Focus() + s.list.SetSelected(s.selectedSessionInx) + s.list.ScrollToSelected() s.input = textinput.New() s.input.SetVirtualCursor(false) @@ -84,37 +87,18 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) return s, nil } -// SetSize sets the size of the dialog. -func (s *Session) SetSize(width, height int) { - t := s.com.Styles - s.width = width - s.height = height - innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content - t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content - t.Dialog.HelpView.GetVerticalFrameSize() + - t.Dialog.View.GetVerticalFrameSize() - s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding - s.list.SetSize(innerWidth, height-heightOffset) - s.help.SetWidth(width) - - // Now that we know the height we can select the selected session and scroll to it. - s.list.SetSelected(s.selectedSessionInx) - s.list.ScrollToSelected() -} - // ID implements Dialog. func (s *Session) ID() string { return SessionsID } -// Update implements Dialog. -func (s *Session) Update(msg tea.Msg) tea.Msg { +// HandleMsg implements Dialog. +func (s *Session) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, s.keyMap.Close): - return CloseMsg{} + return ActionClose{} case key.Matches(msg, s.keyMap.Previous): s.list.Focus() if s.list.IsSelectedFirst() { @@ -136,7 +120,7 @@ func (s *Session) Update(msg tea.Msg) tea.Msg { case key.Matches(msg, s.keyMap.Select): if item := s.list.SelectedItem(); item != nil { sessionItem := item.(*SessionItem) - return SessionSelectedMsg{sessionItem.Session} + return ActionSelectSession{sessionItem.Session} } default: var cmd tea.Cmd @@ -145,9 +129,7 @@ func (s *Session) Update(msg tea.Msg) tea.Msg { s.list.SetFilter(value) s.list.ScrollToTop() s.list.SetSelected(0) - if cmd != nil { - return cmd() - } + return ActionCmd{cmd} } } return nil @@ -158,16 +140,35 @@ func (s *Session) Cursor() *tea.Cursor { return InputCursor(s.com.Styles, s.input.Cursor()) } -// View implements [Dialog]. -func (s *Session) View() string { +// Draw implements [Dialog]. +func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := s.com.Styles + width := max(0, min(120, area.Dx())) + height := max(0, min(30, area.Dy())) + // TODO: Why do we need this 2? + innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2 + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content + t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content + t.Dialog.HelpView.GetVerticalFrameSize() + + // TODO: Why do we need this 2? + t.Dialog.View.GetVerticalFrameSize() + 2 + s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + s.list.SetSize(innerWidth, height-heightOffset) + s.help.SetWidth(innerWidth) + titleStyle := s.com.Styles.Dialog.Title - dialogStyle := s.com.Styles.Dialog.View.Width(s.width) + dialogStyle := s.com.Styles.Dialog.View.Width(width) header := common.DialogTitle(s.com.Styles, "Switch Session", - max(0, s.width-dialogStyle.GetHorizontalFrameSize()- + max(0, width-dialogStyle.GetHorizontalFrameSize()- titleStyle.GetHorizontalFrameSize())) - return HeaderInputListHelpView(s.com.Styles, s.width, s.list.Height(), header, - s.input.View(), s.list.Render(), s.help.View(s)) + helpView := ansi.Truncate(s.help.View(s), innerWidth, "") + view := HeaderInputListHelpView(s.com.Styles, width, s.list.Height(), header, + s.input.View(), s.list.Render(), helpView) + + cur := s.Cursor() + DrawCenterCursor(scr, area, view, cur) + return cur } // ShortHelp implements [help.KeyMap]. diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index ddff75806e8345ca1140622739933c31055658e6..6d6852b7359d5f19d85349f34eff3b21c0510a05 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -74,7 +74,7 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width } var infoText string - var infoLen int + var infoWidth int lineWidth := width if len(info) > 0 { infoText = fmt.Sprintf(" %s ", info) @@ -84,12 +84,12 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width infoText = t.Subtle.Render(infoText) } - infoLen = lipgloss.Width(infoText) + infoWidth = lipgloss.Width(infoText) } - title = ansi.Truncate(title, max(0, lineWidth), "") - titleLen := lipgloss.Width(title) - gap := strings.Repeat(" ", max(0, lineWidth-titleLen-infoLen)) + title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "") + titleWidth := lipgloss.Width(title) + gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth)) content := title if matches := len(m.MatchedIndexes); matches > 0 { var lastPos int diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index d666cffc796ff309e75be5fc403bea01c4b049b5..adfb20c4be53946ce666719b5b70c417064eb3d0 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -404,6 +404,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.completionsOpen { m.completions.SetFiles(msg.Files) } + default: + if m.dialog.HasDialogs() { + if cmd := m.handleDialogMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } + } } // This logic gets triggered on any message type, but should it? @@ -690,6 +696,93 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea. return tea.Batch(cmds...) } +func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + action := m.dialog.Update(msg) + if action == nil { + return tea.Batch(cmds...) + } + + switch msg := action.(type) { + // Generic dialog messages + case dialog.ActionClose: + m.dialog.CloseFrontDialog() + if m.focus == uiFocusEditor { + cmds = append(cmds, m.textarea.Focus()) + } + case dialog.ActionCmd: + if msg.Cmd != nil { + cmds = append(cmds, msg.Cmd) + } + + // Session dialog messages + case dialog.ActionSelectSession: + m.dialog.CloseDialog(dialog.SessionsID) + cmds = append(cmds, m.loadSession(msg.Session.ID)) + + // Open dialog message + case dialog.ActionOpenDialog: + m.dialog.CloseDialog(dialog.CommandsID) + if cmd := m.openDialog(msg.DialogID); cmd != nil { + cmds = append(cmds, cmd) + } + + // Command dialog messages + case dialog.ActionToggleYoloMode: + yolo := !m.com.App.Permissions.SkipRequests() + m.com.App.Permissions.SetSkipRequests(yolo) + m.setEditorPrompt(yolo) + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionNewSession: + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) + break + } + m.newSession() + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionSummarize: + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session...")) + break + } + err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) + if err != nil { + cmds = append(cmds, uiutil.ReportError(err)) + } + case dialog.ActionToggleHelp: + m.status.ToggleHelp() + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionQuit: + cmds = append(cmds, tea.Quit) + case dialog.ActionSelectModel: + if m.com.App.AgentCoordinator.IsBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) + break + } + + // TODO: Validate model API and authentication here? + + cfg := m.com.Config() + if cfg == nil { + cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found"))) + break + } + + if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil { + cmds = append(cmds, uiutil.ReportError(err)) + } + + // XXX: Should this be in a separate goroutine? + go m.com.App.UpdateAgentModel(context.TODO()) + + modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) + cmds = append(cmds, uiutil.ReportInfo(modelMsg)) + m.dialog.CloseDialog(dialog.ModelsID) + } + + return tea.Batch(cmds...) +} + func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { var cmds []tea.Cmd @@ -729,96 +822,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { // Route all messages to dialog if one is open. if m.dialog.HasDialogs() { - msg := m.dialog.Update(msg) - if msg == nil { - return tea.Batch(cmds...) - } - - switch msg := msg.(type) { - // Generic dialog messages - case dialog.CloseMsg: - m.dialog.CloseFrontDialog() - if m.focus == uiFocusEditor { - cmds = append(cmds, m.textarea.Focus()) - } - - // Session dialog messages - case dialog.SessionSelectedMsg: - m.dialog.CloseDialog(dialog.SessionsID) - cmds = append(cmds, m.loadSession(msg.Session.ID)) - - // Open dialog message - case dialog.OpenDialogMsg: - switch msg.DialogID { - case dialog.SessionsID: - if cmd := m.openSessionsDialog(); cmd != nil { - cmds = append(cmds, cmd) - } - case dialog.ModelsID: - if cmd := m.openModelsDialog(); cmd != nil { - cmds = append(cmds, cmd) - } - default: - // Unknown dialog - break - } - - m.dialog.CloseDialog(dialog.CommandsID) - - // Command dialog messages - case dialog.ToggleYoloModeMsg: - yolo := !m.com.App.Permissions.SkipRequests() - m.com.App.Permissions.SetSkipRequests(yolo) - m.setEditorPrompt(yolo) - m.dialog.CloseDialog(dialog.CommandsID) - case dialog.NewSessionsMsg: - if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) - break - } - m.newSession() - m.dialog.CloseDialog(dialog.CommandsID) - case dialog.CompactMsg: - if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session...")) - break - } - err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) - if err != nil { - cmds = append(cmds, uiutil.ReportError(err)) - } - case dialog.ToggleHelpMsg: - m.status.ToggleHelp() - m.dialog.CloseDialog(dialog.CommandsID) - case dialog.QuitMsg: - cmds = append(cmds, tea.Quit) - case dialog.ModelSelectedMsg: - if m.com.App.AgentCoordinator.IsBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) - break - } - - // TODO: Validate model API and authentication here? - - cfg := m.com.Config() - if cfg == nil { - cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found"))) - break - } - - if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil { - cmds = append(cmds, uiutil.ReportError(err)) - } - - // XXX: Should this be in a separate goroutine? - go m.com.App.UpdateAgentModel(context.TODO()) - - modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) - cmds = append(cmds, uiutil.ReportInfo(modelMsg)) - m.dialog.CloseDialog(dialog.ModelsID) - } - - return tea.Batch(cmds...) + return m.handleDialogMsg(msg) } switch m.state { @@ -1035,7 +1039,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } // Draw implements [uv.Drawable] and draws the UI model. -func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { +func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { layout := m.generateLayout(area.Dx(), area.Dy()) if m.layout != layout { @@ -1132,36 +1136,20 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { }) } - // This needs to come last to overlay on top of everything + // This needs to come last to overlay on top of everything. We always pass + // the full screen bounds because the dialogs will position themselves + // accordingly. if m.dialog.HasDialogs() { - m.dialog.Draw(scr, area) + return m.dialog.Draw(scr, scr.Bounds()) } -} -// Cursor returns the cursor position and properties for the UI model. It -// returns nil if the cursor should not be shown. -func (m *UI) Cursor() *tea.Cursor { - if m.layout.editor.Dy() <= 0 { - // Don't show cursor if editor is not visible - return nil - } - if m.dialog.HasDialogs() { - if front := m.dialog.DialogLast(); front != nil { - c, ok := front.(uiutil.Cursor) - if ok { - cur := c.Cursor() - if cur != nil { - pos := m.dialog.CenterPosition(m.layout.area, front.ID()) - cur.X += pos.Min.X - cur.Y += pos.Min.Y - return cur - } - } - } - return nil - } switch m.focus { case uiFocusEditor: + if m.layout.editor.Dy() <= 0 { + // Don't show cursor if editor is not visible + return nil + } + if m.textarea.Focused() { cur := m.textarea.Cursor() cur.X++ // Adjust for app margins @@ -1181,11 +1169,10 @@ func (m *UI) View() tea.View { var v tea.View v.AltScreen = true v.BackgroundColor = m.com.Styles.Background - v.Cursor = m.Cursor() v.MouseMode = tea.MouseModeCellMotion canvas := uv.NewScreenBuffer(m.width, m.height) - m.Draw(canvas, canvas.Bounds()) + v.Cursor = m.Draw(canvas, canvas.Bounds()) content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines contentLines := strings.Split(content, "\n") @@ -1813,6 +1800,33 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C return tea.Batch(cmds...) } +// openDialog opens a dialog by its ID. +func (m *UI) openDialog(id string) tea.Cmd { + var cmds []tea.Cmd + switch id { + case dialog.SessionsID: + if cmd := m.openSessionsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case dialog.ModelsID: + if cmd := m.openModelsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case dialog.CommandsID: + if cmd := m.openCommandsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case dialog.QuitID: + if cmd := m.openQuitDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + default: + // Unknown dialog + break + } + return tea.Batch(cmds...) +} + // openQuitDialog opens the quit confirmation dialog. func (m *UI) openQuitDialog() tea.Cmd { if m.dialog.ContainsDialog(dialog.QuitID) { @@ -1839,7 +1853,6 @@ func (m *UI) openModelsDialog() tea.Cmd { return uiutil.ReportError(err) } - modelsDialog.SetSize(min(60, m.width-8), 30) m.dialog.OpenDialog(modelsDialog) return nil @@ -1863,8 +1876,6 @@ func (m *UI) openCommandsDialog() tea.Cmd { return uiutil.ReportError(err) } - // TODO: Get. Rid. Of. Magic numbers! - commands.SetSize(min(120, m.width-8), 30) m.dialog.OpenDialog(commands) return nil @@ -1890,8 +1901,6 @@ func (m *UI) openSessionsDialog() tea.Cmd { return uiutil.ReportError(err) } - // TODO: Get. Rid. Of. Magic numbers! - dialog.SetSize(min(120, m.width-8), 30) m.dialog.OpenDialog(dialog) return nil @@ -1915,6 +1924,10 @@ func (m *UI) newSession() { // handlePasteMsg handles a paste message. func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { + if m.dialog.HasDialogs() { + return m.handleDialogMsg(msg) + } + if m.focus != uiFocusEditor { return nil } From 4379251cfe71ddc892b80d3385a3b0b9e6ba54db Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 13 Jan 2026 11:36:27 +0100 Subject: [PATCH 132/335] Refactored permissions dialog (#1796) Co-authored-by: Ayman Bagabas --- internal/ui/chat/agent.go | 22 +- internal/ui/chat/bash.go | 22 +- internal/ui/chat/diagnostics.go | 6 +- internal/ui/chat/fetch.go | 18 +- internal/ui/chat/file.go | 28 +- internal/ui/chat/search.go | 24 +- internal/ui/chat/todos.go | 6 +- internal/ui/chat/tools.go | 137 +++--- internal/ui/common/scrollbar.go | 46 ++ internal/ui/dialog/actions.go | 5 + internal/ui/dialog/commands.go | 17 +- internal/ui/dialog/dialog.go | 12 + internal/ui/dialog/models.go | 14 +- internal/ui/dialog/permissions.go | 695 ++++++++++++++++++++++++++++++ internal/ui/dialog/sessions.go | 12 +- internal/ui/model/ui.go | 136 ++++++ internal/ui/styles/styles.go | 13 + 17 files changed, 1068 insertions(+), 145 deletions(-) create mode 100644 internal/ui/common/scrollbar.go create mode 100644 internal/ui/dialog/permissions.go diff --git a/internal/ui/chat/agent.go b/internal/ui/chat/agent.go index f13f9f03f69df57ac61406f69aa3fe30c22d8f07..c2a439ff23d0bd046b75076ea30de68b60cdcc54 100644 --- a/internal/ui/chat/agent.go +++ b/internal/ui/chat/agent.go @@ -47,14 +47,14 @@ func NewAgentToolMessageItem( t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled) // For the agent tool we keep spinning until the tool call is finished. t.spinningFunc = func(state SpinningState) bool { - return state.Result == nil && !state.Canceled + return !state.HasResult() && !state.IsCanceled() } return t } // Animate progresses the message animation if it should be spinning. func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { - if a.result != nil || a.canceled { + if a.result != nil || a.Status() == ToolStatusCanceled { return nil } if msg.ID == a.ID() { @@ -100,7 +100,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.Canceled && len(r.agent.nestedTools) == 0 { + if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 { return pendingTool(sty, "Agent", opts.Anim) } @@ -110,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", cappedWidth, opts.Compact) + header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts.Compact) if opts.Compact { return header } @@ -149,14 +149,14 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String()) // Show animation if still running. - if opts.Result == nil && !opts.Canceled { + if !opts.HasResult() && !opts.IsCanceled() { parts = append(parts, "", opts.Anim.Render()) } result := lipgloss.JoinVertical(lipgloss.Left, parts...) // Add body content when completed. - if opts.Result != nil && opts.Result.Content != "" { + if opts.HasResult() && opts.Result.Content != "" { body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent) return joinToolParts(result, body) } @@ -191,7 +191,7 @@ func NewAgenticFetchToolMessageItem( t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled) // For the agentic fetch tool we keep spinning until the tool call is finished. t.spinningFunc = func(state SpinningState) bool { - return state.Result == nil && !state.Canceled + return !state.HasResult() && !state.IsCanceled() } return t } @@ -231,7 +231,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.Canceled && len(r.fetch.nestedTools) == 0 { + if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 { return pendingTool(sty, "Agentic Fetch", opts.Anim) } @@ -247,7 +247,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", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -286,14 +286,14 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String()) // Show animation if still running. - if opts.Result == nil && !opts.Canceled { + if !opts.HasResult() && !opts.IsCanceled() { parts = append(parts, "", opts.Anim.Render()) } result := lipgloss.JoinVertical(lipgloss.Left, parts...) // Add body content when completed. - if opts.Result != nil && opts.Result.Content != "" { + if opts.HasResult() && opts.Result.Content != "" { body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent) return joinToolParts(result, body) } diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index 0202780cf1e670b48cb7f9a8b9d27a0fe44f5405..18be27ee01b4fcc21749789fc65ec0b71c2b0d4b 100644 --- a/internal/ui/chat/bash.go +++ b/internal/ui/chat/bash.go @@ -40,7 +40,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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Bash", opts.Anim) } @@ -51,7 +51,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * // Check if this is a background job. var meta tools.BashResponseMetadata - if opts.Result != nil { + if opts.HasResult() { _ = json.Unmarshal([]byte(opts.Result.Metadata), &meta) } @@ -69,7 +69,7 @@ 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", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -78,7 +78,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return joinToolParts(header, earlyState) } - if opts.Result == nil { + if !opts.HasResult() { return header } @@ -122,7 +122,7 @@ 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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Job", opts.Anim) } @@ -132,7 +132,7 @@ func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, o } var description string - if opts.Result != nil && opts.Result.Metadata != "" { + if opts.HasResult() && opts.Result.Metadata != "" { var meta tools.JobOutputResponseMetadata if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { description = cmp.Or(meta.Description, meta.Command) @@ -140,7 +140,7 @@ func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, o } content := "" - if opts.Result != nil { + if opts.HasResult() { content = opts.Result.Content } return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content) @@ -173,7 +173,7 @@ 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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Job", opts.Anim) } @@ -183,7 +183,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt } var description string - if opts.Result != nil && opts.Result.Metadata != "" { + if opts.HasResult() && opts.Result.Metadata != "" { var meta tools.JobKillResponseMetadata if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { description = cmp.Or(meta.Description, meta.Command) @@ -191,7 +191,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt } content := "" - if opts.Result != nil { + if opts.HasResult() { content = opts.Result.Content } return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content) @@ -200,7 +200,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt // renderJobTool renders a job-related tool with the common pattern: // header → nested check → early state → body. func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string { - header := jobHeader(sty, opts.Status(), action, shellID, description, width) + header := jobHeader(sty, opts.Status, action, shellID, description, width) if opts.Compact { return header } diff --git a/internal/ui/chat/diagnostics.go b/internal/ui/chat/diagnostics.go index 8ca5436b9082033a9cbb0debedffec041833ea11..68d2ac4a00dc880c27904468008fb8f6b2fcf9c5 100644 --- a/internal/ui/chat/diagnostics.go +++ b/internal/ui/chat/diagnostics.go @@ -36,7 +36,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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Diagnostics", opts.Anim) } @@ -49,7 +49,7 @@ 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", cappedWidth, opts.Compact, mainParam) if opts.Compact { return header } @@ -58,7 +58,7 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } diff --git a/internal/ui/chat/fetch.go b/internal/ui/chat/fetch.go index 41e35c90004a76337e8ce3d59908cadf32ed699f..e3f3a809550385dfd0ec557e98151ffc731acc93 100644 --- a/internal/ui/chat/fetch.go +++ b/internal/ui/chat/fetch.go @@ -35,7 +35,7 @@ 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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Fetch", opts.Anim) } @@ -52,7 +52,7 @@ 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", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -61,7 +61,7 @@ func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } @@ -110,7 +110,7 @@ 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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Fetch", opts.Anim) } @@ -120,7 +120,7 @@ func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, op } toolParams := []string{params.URL} - header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -129,7 +129,7 @@ func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, op return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } @@ -164,7 +164,7 @@ 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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Search", opts.Anim) } @@ -174,7 +174,7 @@ func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, o } toolParams := []string{params.Query} - header := toolHeader(sty, opts.Status(), "Search", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Search", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -183,7 +183,7 @@ func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, o return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } diff --git a/internal/ui/chat/file.go b/internal/ui/chat/file.go index ca0e0b4934e806bbed0c7826161bb2c91a10843f..d558f79d597871bf6074d33c76b44549ee6725d5 100644 --- a/internal/ui/chat/file.go +++ b/internal/ui/chat/file.go @@ -38,7 +38,7 @@ 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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "View", opts.Anim) } @@ -56,7 +56,7 @@ 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", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -65,7 +65,7 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return joinToolParts(header, earlyState) } - if opts.Result == nil { + if !opts.HasResult() { return header } @@ -118,7 +118,7 @@ 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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Write", opts.Anim) } @@ -128,7 +128,7 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts } file := fsext.PrettyPath(params.FilePath) - header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Compact, file) + header := toolHeader(sty, opts.Status, "Write", cappedWidth, opts.Compact, file) if opts.Compact { return header } @@ -173,7 +173,7 @@ type EditToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { // Edit tool uses full width for diffs. - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Edit", opts.Anim) } @@ -183,7 +183,7 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * } file := fsext.PrettyPath(params.FilePath) - header := toolHeader(sty, opts.Status(), "Edit", width, opts.Compact, file) + header := toolHeader(sty, opts.Status, "Edit", width, opts.Compact, file) if opts.Compact { return header } @@ -192,7 +192,7 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return joinToolParts(header, earlyState) } - if opts.Result == nil { + if !opts.HasResult() { return header } @@ -236,7 +236,7 @@ type MultiEditToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { // MultiEdit tool uses full width for diffs. - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Multi-Edit", opts.Anim) } @@ -251,7 +251,7 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits))) } - header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Multi-Edit", width, opts.Compact, toolParams...) if opts.Compact { return header } @@ -260,7 +260,7 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o return joinToolParts(header, earlyState) } - if opts.Result == nil { + if !opts.HasResult() { return header } @@ -304,7 +304,7 @@ 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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Download", opts.Anim) } @@ -321,7 +321,7 @@ 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", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -330,7 +330,7 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } diff --git a/internal/ui/chat/search.go b/internal/ui/chat/search.go index 3430d7d5c8aebe6e93979284f659e74b60316ca9..2342f671fdaed3bfdcf56619864bd3b60987d8a6 100644 --- a/internal/ui/chat/search.go +++ b/internal/ui/chat/search.go @@ -36,7 +36,7 @@ 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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Glob", opts.Anim) } @@ -50,7 +50,7 @@ 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", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -59,7 +59,7 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if !opts.HasResult() || opts.Result.Content == "" { return header } @@ -95,7 +95,7 @@ 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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Grep", opts.Anim) } @@ -115,7 +115,7 @@ 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", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -124,7 +124,7 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } @@ -160,7 +160,7 @@ 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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "List", opts.Anim) } @@ -175,7 +175,7 @@ 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", cappedWidth, opts.Compact, path) if opts.Compact { return header } @@ -184,7 +184,7 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } @@ -220,7 +220,7 @@ 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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Sourcegraph", opts.Anim) } @@ -237,7 +237,7 @@ 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", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -246,7 +246,7 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } diff --git a/internal/ui/chat/todos.go b/internal/ui/chat/todos.go index 3f92de9b32287270298b8a20c463850a32d110b5..f34e10a093b2b66d4b9993237fdbfe94fb53ecfb 100644 --- a/internal/ui/chat/todos.go +++ b/internal/ui/chat/todos.go @@ -40,7 +40,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.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "To-Do", opts.Anim) } @@ -74,7 +74,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts } // If we have metadata, use it for richer display. - if opts.Result != nil && opts.Result.Metadata != "" { + if opts.HasResult() && opts.Result.Metadata != "" { if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { if meta.IsNew { if meta.JustStarted != "" { @@ -119,7 +119,7 @@ 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", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 016fb1dc32ab81042623cf6750666eba1d42ecd9..5c12279e50af551d8b1686afefb3cc52feda4c6d 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -42,6 +42,8 @@ type ToolMessageItem interface { SetResult(res *message.ToolResult) MessageID() string SetMessageID(id string) + SetStatus(status ToolStatus) + Status() ToolStatus } // Compactable is an interface for tool items that can render in a compacted mode. @@ -54,7 +56,17 @@ type Compactable interface { type SpinningState struct { ToolCall message.ToolCall Result *message.ToolResult - Canceled bool + Status ToolStatus +} + +// IsCanceled returns true if the tool status is canceled. +func (s *SpinningState) IsCanceled() bool { + return s.Status == ToolStatusCanceled +} + +// HasResult returns true if the result is not nil. +func (s *SpinningState) HasResult() bool { + return s.Result != nil } // SpinningFunc is a function type for custom spinning logic. @@ -71,32 +83,34 @@ func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opt // ToolRenderOpts contains the data needed to render a tool call. type ToolRenderOpts struct { - ToolCall message.ToolCall - Result *message.ToolResult - Canceled bool - Anim *anim.Anim - ExpandedContent bool - Compact bool - IsSpinning bool - PermissionRequested bool - PermissionGranted bool -} - -// Status returns the current status of the tool call. -func (opts *ToolRenderOpts) Status() ToolStatus { - if opts.Canceled && opts.Result == nil { - return ToolStatusCanceled - } - if opts.Result != nil { - if opts.Result.IsError { - return ToolStatusError - } - return ToolStatusSuccess - } - if opts.PermissionRequested && !opts.PermissionGranted { - return ToolStatusAwaitingPermission - } - return ToolStatusRunning + ToolCall message.ToolCall + Result *message.ToolResult + Anim *anim.Anim + ExpandedContent bool + Compact bool + IsSpinning bool + Status ToolStatus +} + +// IsPending returns true if the tool call is still pending (not finished and +// not canceled). +func (o *ToolRenderOpts) IsPending() bool { + return !o.ToolCall.Finished && !o.IsCanceled() +} + +// IsCanceled returns true if the tool status is canceled. +func (o *ToolRenderOpts) IsCanceled() bool { + return o.Status == ToolStatusCanceled +} + +// HasResult returns true if the result is not nil. +func (o *ToolRenderOpts) HasResult() bool { + return o.Result != nil +} + +// HasEmptyResult returns true if the result is nil or has empty content. +func (o *ToolRenderOpts) HasEmptyResult() bool { + return o.Result == nil || o.Result.Content == "" } // ToolRenderer represents an interface for rendering tool calls. @@ -118,13 +132,11 @@ type baseToolMessageItem struct { *cachedMessageItem *focusableMessageItem - toolRenderer ToolRenderer - toolCall message.ToolCall - result *message.ToolResult - messageID string - canceled bool - permissionRequested bool - permissionGranted bool + toolRenderer ToolRenderer + toolCall message.ToolCall + result *message.ToolResult + messageID string + status ToolStatus // we use this so we can efficiently cache // tools that have a capped width (e.x bash.. and others) hasCappedWidth bool @@ -150,6 +162,11 @@ func newBaseToolMessageItem( // we only do full width for diffs (as far as I know) hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName + status := ToolStatusRunning + if canceled { + status = ToolStatusCanceled + } + t := &baseToolMessageItem{ highlightableMessageItem: defaultHighlighter(sty), cachedMessageItem: &cachedMessageItem{}, @@ -158,7 +175,7 @@ func newBaseToolMessageItem( toolRenderer: toolRenderer, toolCall: toolCall, result: result, - canceled: canceled, + status: status, hasCappedWidth: hasCappedWidth, } t.anim = anim.New(anim.Settings{ @@ -285,15 +302,13 @@ func (t *baseToolMessageItem) Render(width int) string { // if we are spinning or there is no cache rerender if !ok || t.isSpinning() { content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{ - ToolCall: t.toolCall, - Result: t.result, - Canceled: t.canceled, - Anim: t.anim, - ExpandedContent: t.expandedContent, - Compact: t.isCompact, - PermissionRequested: t.permissionRequested, - PermissionGranted: t.permissionGranted, - IsSpinning: t.isSpinning(), + ToolCall: t.toolCall, + Result: t.result, + Anim: t.anim, + ExpandedContent: t.expandedContent, + Compact: t.isCompact, + IsSpinning: t.isSpinning(), + Status: t.computeStatus(), }) height = lipgloss.Height(content) // cache the rendered content @@ -331,20 +346,26 @@ func (t *baseToolMessageItem) SetMessageID(id string) { t.messageID = id } -// SetPermissionRequested sets whether permission has been requested for this tool call. -// TODO: Consider merging with SetPermissionGranted and add an interface for -// permission management. -func (t *baseToolMessageItem) SetPermissionRequested(requested bool) { - t.permissionRequested = requested +// SetStatus sets the tool status. +func (t *baseToolMessageItem) SetStatus(status ToolStatus) { + t.status = status t.clearCache() } -// SetPermissionGranted sets whether permission has been granted for this tool call. -// TODO: Consider merging with SetPermissionRequested and add an interface for -// permission management. -func (t *baseToolMessageItem) SetPermissionGranted(granted bool) { - t.permissionGranted = granted - t.clearCache() +// Status returns the current tool status. +func (t *baseToolMessageItem) Status() ToolStatus { + return t.status +} + +// computeStatus computes the effective status considering the result. +func (t *baseToolMessageItem) computeStatus() ToolStatus { + if t.result != nil { + if t.result.IsError { + return ToolStatusError + } + return ToolStatusSuccess + } + return t.status } // isSpinning returns true if the tool should show animation. @@ -353,10 +374,10 @@ func (t *baseToolMessageItem) isSpinning() bool { return t.spinningFunc(SpinningState{ ToolCall: t.toolCall, Result: t.result, - Canceled: t.canceled, + Status: t.status, }) } - return !t.toolCall.Finished && !t.canceled + return !t.toolCall.Finished && t.status != ToolStatusCanceled } // SetSpinningFunc sets a custom function to determine if the tool should spin. @@ -396,7 +417,7 @@ func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string { // Returns the rendered output and true if early state was handled. func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) { var msg string - switch opts.Status() { + switch opts.Status { case ToolStatusError: msg = toolErrorContent(sty, opts.Result, width) case ToolStatusCanceled: diff --git a/internal/ui/common/scrollbar.go b/internal/ui/common/scrollbar.go new file mode 100644 index 0000000000000000000000000000000000000000..7e701659348c90100534c18620f5e9949db3d050 --- /dev/null +++ b/internal/ui/common/scrollbar.go @@ -0,0 +1,46 @@ +package common + +import ( + "strings" + + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// Scrollbar renders a vertical scrollbar based on content and viewport size. +// Returns an empty string if content fits within viewport (no scrolling needed). +func Scrollbar(s *styles.Styles, height, contentSize, viewportSize, offset int) string { + if height <= 0 || contentSize <= viewportSize { + return "" + } + + // Calculate thumb size (minimum 1 character). + thumbSize := max(1, height*viewportSize/contentSize) + + // Calculate thumb position. + maxOffset := contentSize - viewportSize + if maxOffset <= 0 { + return "" + } + + // Calculate where the thumb starts. + trackSpace := height - thumbSize + thumbPos := 0 + if trackSpace > 0 && maxOffset > 0 { + thumbPos = min(trackSpace, offset*trackSpace/maxOffset) + } + + // Build the scrollbar. + var sb strings.Builder + for i := range height { + if i > 0 { + sb.WriteString("\n") + } + if i >= thumbPos && i < thumbPos+thumbSize { + sb.WriteString(s.Dialog.ScrollbarThumb.Render(styles.ScrollbarThumb)) + } else { + sb.WriteString(s.Dialog.ScrollbarTrack.Render(styles.ScrollbarTrack)) + } + } + + return sb.String() +} diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index a152a125b179e7f14fd69361f236ce9d76e8effa..a9b785eaf1ce7a2d11b24245bd3c51b166da680b 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -3,6 +3,7 @@ package dialog import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" ) @@ -39,6 +40,10 @@ type ( ActionSummarize struct { SessionID string } + ActionPermissionResponse struct { + Permission permission.PermissionRequest + Action PermissionAction + } ) // ActionCmd represents an action that carries a [tea.Cmd] to be passed to the diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index ec62320909445f96e3063747f6fea3d77628e6e4..8211016b95fb1e71b5cb64699d2d0fd12930ee84 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -220,16 +220,15 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm // Draw implements [Dialog]. func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := c.com.Styles - width := max(0, min(100, area.Dx())) - height := max(0, min(30, area.Dy())) + width := max(0, min(defaultDialogMaxWidth, area.Dx())) + height := max(0, min(defaultDialogHeight, area.Dy())) c.width = width - // TODO: Why do we need this 2? - innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2 - heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content - t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content + innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize() + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + t.Dialog.HelpView.GetVerticalFrameSize() + - // TODO: Why do we need this 2? - t.Dialog.View.GetVerticalFrameSize() + 2 + t.Dialog.View.GetVerticalFrameSize() + c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding c.list.SetSize(innerWidth, height-heightOffset) c.help.SetWidth(innerWidth) @@ -416,7 +415,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { cfg := c.com.Config() agentCfg := cfg.Agents[config.AgentCoder] model := cfg.GetModelByType(agentCfg.Model) - if model.SupportsImages { + if model != nil && model.SupportsImages { commands = append(commands, uicmd.Command{ ID: "file_picker", Title: "Open File Picker", diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index f51ff0a4ee8390c8b889ccb1f3f3c2ba60c39532..68eb313d4ec83cf8d098fcfccb5ebf27de8bd0d1 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -8,6 +8,18 @@ import ( uv "github.com/charmbracelet/ultraviolet" ) +// Dialog sizing constants. +const ( + // defaultDialogMaxWidth is the maximum width for standard dialogs. + defaultDialogMaxWidth = 120 + // defaultDialogHeight is the default height for standard dialogs. + defaultDialogHeight = 30 + // titleContentHeight is the height of the title content line. + titleContentHeight = 1 + // inputContentHeight is the height of the input content line. + inputContentHeight = 1 +) + // CloseKey is the default key binding to close dialogs. var CloseKey = key.NewBinding( key.WithKeys("esc", "alt+esc"), diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 344db9cd3a2dbf96f73465d66449d2f60a9df7c3..41d0efe8d21d0dce6fc6ace138a88304dea1123a 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -241,15 +241,13 @@ func (m *Models) modelTypeRadioView() string { // Draw implements [Dialog]. func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := m.com.Styles - width := max(0, min(60, area.Dx())) - height := max(0, min(30, area.Dy())) - // TODO: Why do we need this 2? - innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2 - heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content - t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content + width := max(0, min(defaultDialogMaxWidth, area.Dx())) + height := max(0, min(defaultDialogHeight, area.Dy())) + innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + t.Dialog.HelpView.GetVerticalFrameSize() + - // TODO: Why do we need this 2? - t.Dialog.View.GetVerticalFrameSize() + 2 + t.Dialog.View.GetVerticalFrameSize() m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding m.list.SetSize(innerWidth, height-heightOffset) m.help.SetWidth(innerWidth) diff --git a/internal/ui/dialog/permissions.go b/internal/ui/dialog/permissions.go new file mode 100644 index 0000000000000000000000000000000000000000..87d592807f578d452c7a8f3a28931847426b8f62 --- /dev/null +++ b/internal/ui/dialog/permissions.go @@ -0,0 +1,695 @@ +package dialog + +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/ui/common" + uv "github.com/charmbracelet/ultraviolet" +) + +// PermissionsID is the identifier for the permissions dialog. +const PermissionsID = "permissions" + +// PermissionAction represents the user's response to a permission request. +type PermissionAction string + +const ( + PermissionAllow PermissionAction = "allow" + PermissionAllowForSession PermissionAction = "allow_session" + PermissionDeny PermissionAction = "deny" +) + +// Permissions dialog sizing constants. +const ( + // diffMaxWidth is the maximum width for diff views. + diffMaxWidth = 180 + // diffSizeRatio is the size ratio for diff views relative to window. + diffSizeRatio = 0.8 + // simpleMaxWidth is the maximum width for simple content dialogs. + simpleMaxWidth = 100 + // simpleSizeRatio is the size ratio for simple content dialogs. + simpleSizeRatio = 0.6 + // simpleHeightRatio is the height ratio for simple content dialogs. + simpleHeightRatio = 0.5 + // splitModeMinWidth is the minimum width to enable split diff mode. + splitModeMinWidth = 140 + // layoutSpacingLines is the number of empty lines used for layout spacing. + layoutSpacingLines = 4 + // minWindowWidth is the minimum window width before forcing fullscreen. + minWindowWidth = 60 + // minWindowHeight is the minimum window height before forcing fullscreen. + minWindowHeight = 20 +) + +// Permissions represents a dialog for permission requests. +type Permissions struct { + com *common.Common + windowWidth int // Terminal window dimensions. + windowHeight int + fullscreen bool // true when dialog is fullscreen + + permission permission.PermissionRequest + selectedOption int // 0: Allow, 1: Allow for session, 2: Deny + + viewport viewport.Model + viewportDirty bool // true when viewport content needs to be re-rendered + viewportWidth int + + // Diff view state. + diffSplitMode *bool // nil means use default based on width + defaultDiffSplitMode bool // default split mode based on width + unifiedDiffContent string + splitDiffContent string + + help help.Model + keyMap permissionsKeyMap +} + +type permissionsKeyMap struct { + Left key.Binding + Right key.Binding + Tab key.Binding + Select key.Binding + Allow key.Binding + AllowSession key.Binding + Deny key.Binding + Close key.Binding + ToggleDiffMode key.Binding + ToggleFullscreen key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + ScrollLeft key.Binding + ScrollRight key.Binding + Choose key.Binding + Scroll key.Binding +} + +func defaultPermissionsKeyMap() permissionsKeyMap { + return permissionsKeyMap{ + 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", "next option"), + ), + Select: key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ), + 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"), + key.WithHelp("d", "deny"), + ), + Close: CloseKey, + ToggleDiffMode: key.NewBinding( + key.WithKeys("t"), + key.WithHelp("t", "toggle diff view"), + ), + ToggleFullscreen: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "toggle fullscreen"), + ), + ScrollUp: key.NewBinding( + key.WithKeys("shift+up", "K"), + key.WithHelp("shift+↑", "scroll up"), + ), + ScrollDown: key.NewBinding( + key.WithKeys("shift+down", "J"), + key.WithHelp("shift+↓", "scroll down"), + ), + 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"), + ), + Choose: key.NewBinding( + key.WithKeys("left", "right"), + key.WithHelp("←/→", "choose"), + ), + Scroll: key.NewBinding( + key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"), + key.WithHelp("shift+←↓↑→", "scroll"), + ), + } +} + +var _ Dialog = (*Permissions)(nil) + +// PermissionsOption configures the permissions dialog. +type PermissionsOption func(*Permissions) + +// WithDiffMode sets the initial diff mode (split or unified). +func WithDiffMode(split bool) PermissionsOption { + return func(p *Permissions) { + p.diffSplitMode = &split + } +} + +// NewPermissions creates a new permissions dialog. +func NewPermissions(com *common.Common, perm permission.PermissionRequest, opts ...PermissionsOption) *Permissions { + h := help.New() + h.Styles = com.Styles.DialogHelpStyles() + + km := defaultPermissionsKeyMap() + + // Configure viewport with matching keybindings. + vp := viewport.New() + vp.KeyMap = viewport.KeyMap{ + Up: km.ScrollUp, + Down: km.ScrollDown, + Left: km.ScrollLeft, + Right: km.ScrollRight, + // Disable other viewport keys to avoid conflicts with dialog shortcuts. + PageUp: key.NewBinding(key.WithDisabled()), + PageDown: key.NewBinding(key.WithDisabled()), + HalfPageUp: key.NewBinding(key.WithDisabled()), + HalfPageDown: key.NewBinding(key.WithDisabled()), + } + + p := &Permissions{ + com: com, + permission: perm, + selectedOption: 0, + viewport: vp, + help: h, + keyMap: km, + } + + for _, opt := range opts { + opt(p) + } + + return p +} + +// Calculate usable content width (dialog border + horizontal padding). +func (p *Permissions) calculateContentWidth(width int) int { + t := p.com.Styles + const dialogHorizontalPadding = 2 + return width - t.Dialog.View.GetHorizontalFrameSize() - dialogHorizontalPadding +} + +// ID implements [Dialog]. +func (*Permissions) ID() string { + return PermissionsID +} + +// HandleMsg implements [Dialog]. +func (p *Permissions) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, p.keyMap.Close): + // Escape denies the permission request. + return p.respond(PermissionDeny) + case key.Matches(msg, p.keyMap.Right), key.Matches(msg, p.keyMap.Tab): + p.selectedOption = (p.selectedOption + 1) % 3 + case key.Matches(msg, p.keyMap.Left): + // Add 2 instead of subtracting 1 to avoid negative modulo. + p.selectedOption = (p.selectedOption + 2) % 3 + case key.Matches(msg, p.keyMap.Select): + return p.selectCurrentOption() + case key.Matches(msg, p.keyMap.Allow): + return p.respond(PermissionAllow) + case key.Matches(msg, p.keyMap.AllowSession): + return p.respond(PermissionAllowForSession) + case key.Matches(msg, p.keyMap.Deny): + return p.respond(PermissionDeny) + case key.Matches(msg, p.keyMap.ToggleDiffMode): + if p.hasDiffView() { + newMode := !p.isSplitMode() + p.diffSplitMode = &newMode + p.viewportDirty = true + } + case key.Matches(msg, p.keyMap.ToggleFullscreen): + if p.hasDiffView() { + p.fullscreen = !p.fullscreen + } + case key.Matches(msg, p.keyMap.ScrollDown): + p.viewport, _ = p.viewport.Update(msg) + case key.Matches(msg, p.keyMap.ScrollUp): + p.viewport, _ = p.viewport.Update(msg) + case key.Matches(msg, p.keyMap.ScrollLeft): + p.viewport, _ = p.viewport.Update(msg) + case key.Matches(msg, p.keyMap.ScrollRight): + p.viewport, _ = p.viewport.Update(msg) + } + case tea.MouseWheelMsg: + p.viewport, _ = p.viewport.Update(msg) + default: + // Pass unhandled keys to viewport for non-diff content scrolling. + if !p.hasDiffView() { + p.viewport, _ = p.viewport.Update(msg) + p.viewportDirty = true + } + } + + return nil +} + +func (p *Permissions) selectCurrentOption() tea.Msg { + switch p.selectedOption { + case 0: + return p.respond(PermissionAllow) + case 1: + return p.respond(PermissionAllowForSession) + default: + return p.respond(PermissionDeny) + } +} + +func (p *Permissions) respond(action PermissionAction) tea.Msg { + return ActionPermissionResponse{ + Permission: p.permission, + Action: action, + } +} + +func (p *Permissions) hasDiffView() bool { + switch p.permission.ToolName { + case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName: + return true + } + return false +} + +func (p *Permissions) isSplitMode() bool { + if p.diffSplitMode != nil { + return *p.diffSplitMode + } + return p.defaultDiffSplitMode +} + +// Draw implements [Dialog]. +func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := p.com.Styles + // Force fullscreen when window is too small. + forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight + + // Calculate dialog dimensions based on fullscreen state and content type. + var width, height int + if forceFullscreen || (p.fullscreen && p.hasDiffView()) { + // Use nearly full window for fullscreen. + width = area.Dx() + height = area.Dy() + } else if p.hasDiffView() { + // Wide for side-by-side diffs, capped for readability. + width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth) + height = int(float64(area.Dy()) * diffSizeRatio) + } else { + // Narrower for simple content like commands/URLs. + width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth) + height = int(float64(area.Dy()) * simpleHeightRatio) + } + + dialogStyle := t.Dialog.View.Width(width).Padding(0, 1) + + contentWidth := p.calculateContentWidth(width) + header := p.renderHeader(contentWidth) + buttons := p.renderButtons(contentWidth) + helpView := p.help.View(p) + + // Calculate available height for content. + headerHeight := lipgloss.Height(header) + buttonsHeight := lipgloss.Height(buttons) + helpHeight := lipgloss.Height(helpView) + frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines + availableHeight := height - headerHeight - buttonsHeight - helpHeight - frameHeight + + p.defaultDiffSplitMode = width >= splitModeMinWidth + + if p.viewport.Width() != contentWidth-1 { + // Mark diff content as dirty if width has changed + p.viewportDirty = true + } + + var content string + var scrollbar string + // Non-diff content uses the viewport for scrolling. + p.viewport.SetWidth(contentWidth - 1) // -1 for scrollbar + p.viewport.SetHeight(availableHeight) + if p.viewportDirty { + p.viewport.SetContent(p.renderContent(contentWidth - 1)) + p.viewportWidth = p.viewport.Width() + p.viewportDirty = false + } + content = p.viewport.View() + if p.canScroll() { + scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset()) + } + + // Join content with scrollbar if present. + if scrollbar != "" { + content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar) + } + + parts := []string{header} + if content != "" { + parts = append(parts, "", content) + } + parts = append(parts, "", buttons, "", helpView) + + innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...) + DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil) + return nil +} + +func (p *Permissions) renderHeader(contentWidth int) string { + t := p.com.Styles + + title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize()) + title = t.Dialog.Title.Render(title) + + // Tool info. + toolLine := p.renderKeyValue("Tool", p.permission.ToolName, contentWidth) + pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth) + + lines := []string{title, "", toolLine, pathLine} + + // Add tool-specific header info. + switch p.permission.ToolName { + case tools.BashToolName: + if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok { + lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth)) + } + case tools.DownloadToolName: + if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok { + lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth)) + lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth)) + } + case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName: + var filePath string + switch params := p.permission.Params.(type) { + case tools.EditPermissionsParams: + filePath = params.FilePath + case tools.WritePermissionsParams: + filePath = params.FilePath + case tools.MultiEditPermissionsParams: + filePath = params.FilePath + case tools.ViewPermissionsParams: + filePath = params.FilePath + } + if filePath != "" { + lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth)) + } + case tools.LSToolName: + if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok { + lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth)) + } + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (p *Permissions) renderKeyValue(key, value string, width int) string { + t := p.com.Styles + keyStyle := t.Muted + valueStyle := t.Base + + keyStr := keyStyle.Render(key) + valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value) + + return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr) +} + +func (p *Permissions) renderContent(width int) string { + switch p.permission.ToolName { + case tools.BashToolName: + return p.renderBashContent() + case tools.EditToolName: + return p.renderEditContent(width) + case tools.WriteToolName: + return p.renderWriteContent(width) + case tools.MultiEditToolName: + return p.renderMultiEditContent(width) + case tools.DownloadToolName: + return p.renderDownloadContent() + case tools.FetchToolName: + return p.renderFetchContent() + case tools.AgenticFetchToolName: + return p.renderAgenticFetchContent() + case tools.ViewToolName: + return p.renderViewContent() + case tools.LSToolName: + return p.renderLSContent() + default: + return p.renderDefaultContent() + } +} + +func (p *Permissions) renderBashContent() string { + params, ok := p.permission.Params.(tools.BashPermissionsParams) + if !ok { + return "" + } + + return p.com.Styles.Dialog.ContentPanel.Render(params.Command) +} + +func (p *Permissions) renderEditContent(contentWidth int) string { + params, ok := p.permission.Params.(tools.EditPermissionsParams) + if !ok { + return "" + } + return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth) +} + +func (p *Permissions) renderWriteContent(contentWidth int) string { + params, ok := p.permission.Params.(tools.WritePermissionsParams) + if !ok { + return "" + } + return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth) +} + +func (p *Permissions) renderMultiEditContent(contentWidth int) string { + params, ok := p.permission.Params.(tools.MultiEditPermissionsParams) + if !ok { + return "" + } + return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth) +} + +func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string { + if !p.viewportDirty { + if p.isSplitMode() { + return p.splitDiffContent + } + return p.unifiedDiffContent + } + + isSplitMode := p.isSplitMode() + formatter := common.DiffFormatter(p.com.Styles). + Before(fsext.PrettyPath(filePath), oldContent). + After(fsext.PrettyPath(filePath), newContent). + // TODO: Allow horizontal scrolling instead of cropping. However, the + // diffview currently would only background color the width of the + // content. If the viewport is wider than the content, the rest of the + // line would not be colored properly. + Width(contentWidth) + + var result string + if isSplitMode { + formatter = formatter.Split() + p.splitDiffContent = formatter.String() + result = p.splitDiffContent + } else { + formatter = formatter.Unified() + p.unifiedDiffContent = formatter.String() + result = p.unifiedDiffContent + } + + return result +} + +func (p *Permissions) renderDownloadContent() string { + params, ok := p.permission.Params.(tools.DownloadPermissionsParams) + if !ok { + return "" + } + + content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath)) + if params.Timeout > 0 { + content += fmt.Sprintf("\nTimeout: %ds", params.Timeout) + } + + return p.com.Styles.Dialog.ContentPanel.Render(content) +} + +func (p *Permissions) renderFetchContent() string { + params, ok := p.permission.Params.(tools.FetchPermissionsParams) + if !ok { + return "" + } + + return p.com.Styles.Dialog.ContentPanel.Render(params.URL) +} + +func (p *Permissions) renderAgenticFetchContent() string { + params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams) + if !ok { + return "" + } + + var content string + if params.URL != "" { + content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt) + } else { + content = fmt.Sprintf("Prompt: %s", params.Prompt) + } + + return p.com.Styles.Dialog.ContentPanel.Render(content) +} + +func (p *Permissions) renderViewContent() string { + params, ok := p.permission.Params.(tools.ViewPermissionsParams) + if !ok { + return "" + } + + content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath)) + if params.Offset > 0 { + content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1) + } + if params.Limit > 0 && params.Limit != 2000 { + content += fmt.Sprintf("\nLines to read: %d", params.Limit) + } + + return p.com.Styles.Dialog.ContentPanel.Render(content) +} + +func (p *Permissions) renderLSContent() string { + params, ok := p.permission.Params.(tools.LSPermissionsParams) + if !ok { + return "" + } + + content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path)) + if len(params.Ignore) > 0 { + content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", ")) + } + + return p.com.Styles.Dialog.ContentPanel.Render(content) +} + +func (p *Permissions) renderDefaultContent() string { + content := p.permission.Description + + // Pretty-print JSON params if available. + if p.permission.Params != nil { + var paramStr string + if str, ok := p.permission.Params.(string); ok { + paramStr = str + } else { + paramStr = fmt.Sprintf("%v", p.permission.Params) + } + + 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 if paramStr != "" { + if content != "" { + content += "\n\n" + } + content += paramStr + } + } + + if content == "" { + return "" + } + + return p.com.Styles.Dialog.ContentPanel.Render(strings.TrimSpace(content)) +} + +func (p *Permissions) renderButtons(contentWidth int) string { + buttons := []common.ButtonOpts{ + {Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0}, + {Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1}, + {Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2}, + } + + content := common.ButtonGroup(p.com.Styles, buttons, " ") + + // If buttons are too wide, stack them vertically. + if lipgloss.Width(content) > contentWidth { + content = common.ButtonGroup(p.com.Styles, buttons, "\n") + return lipgloss.NewStyle(). + Width(contentWidth). + Align(lipgloss.Center). + Render(content) + } + + return lipgloss.NewStyle(). + Width(contentWidth). + Align(lipgloss.Right). + Render(content) +} + +func (p *Permissions) canScroll() bool { + if p.hasDiffView() { + // Diff views can always scroll. + return true + } + // For non-diff content, check if viewport has scrollable content. + return !p.viewport.AtTop() || !p.viewport.AtBottom() +} + +// ShortHelp implements [help.KeyMap]. +func (p *Permissions) ShortHelp() []key.Binding { + bindings := []key.Binding{ + p.keyMap.Choose, + p.keyMap.Select, + p.keyMap.Close, + } + + if p.canScroll() { + bindings = append(bindings, p.keyMap.Scroll) + } + + if p.hasDiffView() { + bindings = append(bindings, + p.keyMap.ToggleDiffMode, + p.keyMap.ToggleFullscreen, + ) + } + + return bindings +} + +// FullHelp implements [help.KeyMap]. +func (p *Permissions) FullHelp() [][]key.Binding { + return [][]key.Binding{p.ShortHelp()} +} diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 3773eba6c612d824c57f1886cd017a203bdca9d5..7a4725fcb9fac33d349dd5d6d7812e8f70c00eaa 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -143,15 +143,13 @@ func (s *Session) Cursor() *tea.Cursor { // Draw implements [Dialog]. func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := s.com.Styles - width := max(0, min(120, area.Dx())) - height := max(0, min(30, area.Dy())) - // TODO: Why do we need this 2? + width := max(0, min(defaultDialogMaxWidth, area.Dx())) + height := max(0, min(defaultDialogHeight, area.Dy())) innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2 - heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content - t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + t.Dialog.HelpView.GetVerticalFrameSize() + - // TODO: Why do we need this 2? - t.Dialog.View.GetVerticalFrameSize() + 2 + t.Dialog.View.GetVerticalFrameSize() s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding s.list.SetSize(innerWidth, height-heightOffset) s.help.SetWidth(innerWidth) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index adfb20c4be53946ce666719b5b70c417064eb3d0..2c32c6774a6a83dbb2c858f97239a00521f5fd38 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -13,6 +13,7 @@ import ( "slices" "strconv" "strings" + "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" @@ -74,6 +75,8 @@ type openEditorMsg struct { Text string } +type cancelTimerExpiredMsg struct{} + // UI represents the main user interface model. type UI struct { com *common.Common @@ -94,6 +97,9 @@ type UI struct { dialog *dialog.Overlay status *Status + // isCanceling tracks whether the user has pressed escape once to cancel. + isCanceling bool + // header is the last cached header logo header string @@ -280,6 +286,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } + case pubsub.Event[permission.PermissionRequest]: + if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil { + cmds = append(cmds, cmd) + } + case pubsub.Event[permission.PermissionNotification]: + m.handlePermissionNotification(msg.Payload) + case cancelTimerExpiredMsg: + m.isCanceling = false case tea.TerminalVersionMsg: termVersion := strings.ToLower(msg.Name) // Only enable progress bar for the following terminals. @@ -348,6 +362,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.chat.HandleMouseUp(x, y) } case tea.MouseWheelMsg: + // Pass mouse events to dialogs first if any are open. + if m.dialog.HasDialogs() { + m.dialog.Update(msg) + return m, tea.Batch(cmds...) + } + + // Otherwise handle mouse wheel for chat. switch m.state { case uiChat: switch msg.Button { @@ -778,6 +799,17 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) cmds = append(cmds, uiutil.ReportInfo(modelMsg)) m.dialog.CloseDialog(dialog.ModelsID) + // TODO CHANGE + case dialog.ActionPermissionResponse: + m.dialog.CloseDialog(dialog.PermissionsID) + switch msg.Action { + case dialog.PermissionAllow: + m.com.App.Permissions.Grant(msg.Permission) + case dialog.PermissionAllowForSession: + m.com.App.Permissions.GrantPersistent(msg.Permission) + case dialog.PermissionDeny: + m.com.App.Permissions.Deny(msg.Permission) + } } return tea.Batch(cmds...) @@ -825,6 +857,16 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { return m.handleDialogMsg(msg) } + // Handle cancel key when agent is busy. + if key.Matches(msg, m.keyMap.Chat.Cancel) { + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + if cmd := m.cancelAgent(); cmd != nil { + cmds = append(cmds, cmd) + } + return tea.Batch(cmds...) + } + } + switch m.state { case uiConfigure: return tea.Batch(cmds...) @@ -1207,6 +1249,17 @@ func (m *UI) ShortHelp() []key.Binding { case uiInitialize: binds = append(binds, k.Quit) case uiChat: + // Show cancel binding if agent is busy. + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + cancelBinding := k.Chat.Cancel + if m.isCanceling { + cancelBinding.SetHelp("esc", "press again to cancel") + } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 { + cancelBinding.SetHelp("esc", "clear queue") + } + binds = append(binds, cancelBinding) + } + if m.focus == uiFocusEditor { tab.SetHelp("tab", "focus chat") } else { @@ -1272,6 +1325,17 @@ func (m *UI) FullHelp() [][]key.Binding { k.Quit, }) case uiChat: + // Show cancel binding if agent is busy. + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + cancelBinding := k.Chat.Cancel + if m.isCanceling { + cancelBinding.SetHelp("esc", "press again to cancel") + } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 { + cancelBinding.SetHelp("esc", "clear queue") + } + binds = append(binds, []key.Binding{cancelBinding}) + } + mainBinds := []key.Binding{} tab := k.Tab if m.focus == uiFocusEditor { @@ -1800,6 +1864,46 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C return tea.Batch(cmds...) } +const cancelTimerDuration = 2 * time.Second + +// cancelTimerCmd creates a command that expires the cancel timer. +func cancelTimerCmd() tea.Cmd { + return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg { + return cancelTimerExpiredMsg{} + }) +} + +// cancelAgent handles the cancel key press. The first press sets isCanceling to true +// and starts a timer. The second press (before the timer expires) actually +// cancels the agent. +func (m *UI) cancelAgent() tea.Cmd { + if m.session == nil || m.session.ID == "" { + return nil + } + + coordinator := m.com.App.AgentCoordinator + if coordinator == nil { + return nil + } + + if m.isCanceling { + // Second escape press - actually cancel the agent. + m.isCanceling = false + coordinator.Cancel(m.session.ID) + return nil + } + + // Check if there are queued prompts - if so, clear the queue. + if coordinator.QueuedPrompts(m.session.ID) > 0 { + coordinator.ClearQueue(m.session.ID) + return nil + } + + // First escape press - set canceling state and start timer. + m.isCanceling = true + return cancelTimerCmd() +} + // openDialog opens a dialog by its ID. func (m *UI) openDialog(id string) tea.Cmd { var cmds []tea.Cmd @@ -1906,6 +2010,38 @@ func (m *UI) openSessionsDialog() tea.Cmd { return nil } +// openPermissionsDialog opens the permissions dialog for a permission request. +func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd { + // Close any existing permissions dialog first. + m.dialog.CloseDialog(dialog.PermissionsID) + + // Get diff mode from config. + var opts []dialog.PermissionsOption + if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" { + opts = append(opts, dialog.WithDiffMode(diffMode == "split")) + } + + permDialog := dialog.NewPermissions(m.com, perm, opts...) + m.dialog.OpenDialog(permDialog) + return nil +} + +// handlePermissionNotification updates tool items when permission state changes. +func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) { + toolItem := m.chat.MessageItem(notification.ToolCallID) + if toolItem == nil { + return + } + + if permItem, ok := toolItem.(chat.ToolMessageItem); ok { + if notification.Granted { + permItem.SetStatus(chat.ToolStatusRunning) + } else { + permItem.SetStatus(chat.ToolStatusAwaitingPermission) + } + } +} + // newSession clears the current session state and prepares for a new session. // The actual session creation happens when the user sends their first message. func (m *UI) newSession() { diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 598ca2d0f0216c1d04ac99424e30cc07b56b39a0..12cfd52124fa8fa45488dd34d8556072372f4d8f 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -45,6 +45,9 @@ const ( ImageIcon string = "■" TextIcon string = "≡" + + ScrollbarThumb string = "┃" + ScrollbarTrack string = "│" ) const ( @@ -312,6 +315,13 @@ type Styles struct { List lipgloss.Style + // ContentPanel is used for content blocks with subtle background. + ContentPanel lipgloss.Style + + // Scrollbar styles for scrollable content. + ScrollbarThumb lipgloss.Style + ScrollbarTrack lipgloss.Style + Commands struct{} } @@ -1162,6 +1172,9 @@ func DefaultStyles() Styles { s.Dialog.InputPrompt = base.Margin(1, 1) s.Dialog.List = base.Margin(0, 0, 1, 0) + s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2) + s.Dialog.ScrollbarThumb = base.Foreground(secondary) + s.Dialog.ScrollbarTrack = base.Foreground(border) s.Status.Help = lipgloss.NewStyle().Padding(0, 1) s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!") From dcd664a4a19a9920fb30048962ba7f0f64839bf9 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 13 Jan 2026 11:03:55 -0300 Subject: [PATCH 133/335] feat: implement api key input dialog on new ui codebase (#1836) --- internal/ui/dialog/actions.go | 9 + internal/ui/dialog/api_key_input.go | 302 ++++++++++++++++++++++++++++ internal/ui/dialog/models.go | 1 + internal/ui/model/ui.go | 30 ++- internal/ui/styles/styles.go | 14 +- 5 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 internal/ui/dialog/api_key_input.go diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index a9b785eaf1ce7a2d11b24245bd3c51b166da680b..ecf81432410c31a523d221221e00c50d9862b9ac 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -2,6 +2,7 @@ package dialog import ( tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" @@ -25,6 +26,7 @@ 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 } @@ -46,6 +48,13 @@ type ( } ) +// Messages for API key input dialog. +type ( + ActionChangeAPIKeyState struct { + State APIKeyInputState + } +) + // ActionCmd represents an action that carries a [tea.Cmd] to be passed to the // Bubble Tea program loop. type ActionCmd struct { diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go new file mode 100644 index 0000000000000000000000000000000000000000..57e4df189899426a72e92435044615fa178266c6 --- /dev/null +++ b/internal/ui/dialog/api_key_input.go @@ -0,0 +1,302 @@ +package dialog + +import ( + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/exp/charmtone" +) + +type APIKeyInputState int + +const ( + APIKeyInputStateInitial APIKeyInputState = iota + APIKeyInputStateVerifying + APIKeyInputStateVerified + APIKeyInputStateError +) + +// APIKeyInputID is the identifier for the model selection dialog. +const APIKeyInputID = "api_key_input" + +// APIKeyInput represents a model selection dialog. +type APIKeyInput struct { + com *common.Common + + provider catwalk.Provider + model config.SelectedModel + modelType config.SelectedModelType + + width int + state APIKeyInputState + + keyMap struct { + Submit key.Binding + Close key.Binding + } + input textinput.Model + spinner spinner.Model + help help.Model +} + +var _ Dialog = (*APIKeyInput)(nil) + +// NewAPIKeyInput creates a new Models dialog. +func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, error) { + t := com.Styles + + m := APIKeyInput{} + m.com = com + m.provider = provider + m.model = model + m.modelType = modelType + m.width = 60 + + innerWidth := m.width - t.Dialog.View.GetHorizontalFrameSize() - 2 + + m.input = textinput.New() + m.input.SetVirtualCursor(false) + m.input.Placeholder = "Enter you API key..." + m.input.SetStyles(com.Styles.TextInput) + m.input.Focus() + m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + + m.spinner = spinner.New( + spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(t.Base.Foreground(t.Green)), + ) + + m.help = help.New() + m.help.Styles = t.DialogHelpStyles() + + m.keyMap.Submit = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "submit"), + ) + m.keyMap.Close = CloseKey + + return &m, nil +} + +// ID implements Dialog. +func (m *APIKeyInput) ID() string { + return APIKeyInputID +} + +// Update implements tea.Model. +func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case ActionChangeAPIKeyState: + m.state = msg.State + switch m.state { + case APIKeyInputStateVerifying: + cmd := tea.Batch(m.spinner.Tick, m.verifyAPIKey) + return ActionCmd{cmd} + } + case spinner.TickMsg: + switch m.state { + case APIKeyInputStateVerifying: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + case tea.KeyPressMsg: + switch { + case m.state == APIKeyInputStateVerifying: + // do nothing + case key.Matches(msg, m.keyMap.Close): + switch m.state { + case APIKeyInputStateVerified: + return m.saveKeyAndContinue() + default: + return ActionClose{} + } + case key.Matches(msg, m.keyMap.Submit): + switch m.state { + case APIKeyInputStateInitial, APIKeyInputStateError: + return ActionChangeAPIKeyState{State: APIKeyInputStateVerifying} + case APIKeyInputStateVerified: + return m.saveKeyAndContinue() + } + default: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + case tea.PasteMsg: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + return nil +} + +// View implements tea.Model. +func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := m.com.Styles + + textStyle := t.Dialog.SecondaryText + helpStyle := t.Dialog.HelpView + dialogStyle := t.Dialog.View.Width(m.width) + inputStyle := t.Dialog.InputPrompt + helpStyle = helpStyle.Width(m.width - dialogStyle.GetHorizontalFrameSize()) + + m.input.Prompt = m.spinner.View() + + content := strings.Join([]string{ + m.headerView(), + inputStyle.Render(m.inputView()), + textStyle.Render("This will be written in your global configuration:"), + textStyle.Render(config.GlobalConfigData()), + "", + helpStyle.Render(m.help.View(m)), + }, "\n") + + view := dialogStyle.Render(content) + + cur := m.Cursor() + DrawCenterCursor(scr, area, view, cur) + return cur +} + +func (m *APIKeyInput) headerView() string { + t := m.com.Styles + titleStyle := t.Dialog.Title + dialogStyle := t.Dialog.View.Width(m.width) + + headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() + return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset) +} + +func (m *APIKeyInput) dialogTitle() string { + t := m.com.Styles + textStyle := t.Dialog.TitleText + errorStyle := t.Dialog.TitleError + accentStyle := t.Dialog.TitleAccent + + switch m.state { + case APIKeyInputStateInitial: + return textStyle.Render("Enter your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(".") + case APIKeyInputStateVerifying: + return textStyle.Render("Verifying your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render("...") + case APIKeyInputStateVerified: + return accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(" validated.") + case APIKeyInputStateError: + return errorStyle.Render("Invalid ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + errorStyle.Render(". Try again?") + } + return "" +} + +func (m *APIKeyInput) inputView() string { + t := m.com.Styles + + switch m.state { + case APIKeyInputStateInitial: + m.input.Prompt = "> " + m.input.SetStyles(t.TextInput) + m.input.Focus() + case APIKeyInputStateVerifying: + ts := t.TextInput + ts.Blurred.Prompt = ts.Focused.Prompt + + m.input.Prompt = m.spinner.View() + m.input.SetStyles(ts) + m.input.Blur() + case APIKeyInputStateVerified: + ts := t.TextInput + ts.Blurred.Prompt = ts.Focused.Prompt + + m.input.Prompt = styles.CheckIcon + " " + m.input.SetStyles(ts) + m.input.Blur() + case APIKeyInputStateError: + ts := t.TextInput + ts.Focused.Prompt = ts.Focused.Prompt.Foreground(charmtone.Cherry) + + m.input.Prompt = styles.ErrorIcon + " " + m.input.SetStyles(ts) + m.input.Focus() + } + return m.input.View() +} + +// Cursor returns the cursor position relative to the dialog. +func (c *APIKeyInput) Cursor() *tea.Cursor { + return InputCursor(c.com.Styles, c.input.Cursor()) +} + +// FullHelp returns the full help view. +func (m *APIKeyInput) FullHelp() [][]key.Binding { + return [][]key.Binding{ + { + m.keyMap.Submit, + m.keyMap.Close, + }, + } +} + +// ShortHelp returns the full help view. +func (m *APIKeyInput) ShortHelp() []key.Binding { + return []key.Binding{ + m.keyMap.Submit, + m.keyMap.Close, + } +} + +func (m *APIKeyInput) verifyAPIKey() tea.Msg { + start := time.Now() + + providerConfig := config.ProviderConfig{ + ID: string(m.provider.ID), + Name: m.provider.Name, + APIKey: m.input.Value(), + Type: m.provider.Type, + BaseURL: m.provider.APIEndpoint, + } + err := providerConfig.TestConnection(config.Get().Resolver()) + + // intentionally wait for at least 750ms to make sure the user sees the spinner + elapsed := time.Since(start) + minimum := 750 * time.Millisecond + if elapsed < minimum { + time.Sleep(minimum - elapsed) + } + + if err == nil { + return ActionChangeAPIKeyState{APIKeyInputStateVerified} + } + return ActionChangeAPIKeyState{APIKeyInputStateError} +} + +func (m *APIKeyInput) saveKeyAndContinue() tea.Msg { + cfg := m.com.Config() + + err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value()) + if err != nil { + return uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err)) + } + + return ActionSelectModel{ + Provider: m.provider, + Model: m.model, + ModelType: m.modelType, + } +} diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 41d0efe8d21d0dce6fc6ace138a88304dea1123a..c12c78e1f4753c01f80653bb6ee5e5013fc9ea09 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -189,6 +189,7 @@ func (m *Models) HandleMsg(msg tea.Msg) Action { } return ActionSelectModel{ + Provider: modelItem.prov, Model: modelItem.SelectedModel(), ModelType: modelItem.SelectedModelType(), } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 2c32c6774a6a83dbb2c858f97239a00521f5fd38..620636ad717100db01e3524c500147f7bd8576ee 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -20,6 +20,7 @@ import ( "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/config" @@ -781,14 +782,21 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { break } - // TODO: Validate model API and authentication here? - cfg := m.com.Config() if cfg == nil { cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found"))) break } + _, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider) + if !isProviderConfigured { + m.dialog.CloseDialog(dialog.ModelsID) + if cmd := m.openAPIKeyInputDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil { + cmds = append(cmds, cmd) + } + break + } + if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil { cmds = append(cmds, uiutil.ReportError(err)) } @@ -798,6 +806,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) cmds = append(cmds, uiutil.ReportInfo(modelMsg)) + m.dialog.CloseDialog(dialog.APIKeyInputID) m.dialog.CloseDialog(dialog.ModelsID) // TODO CHANGE case dialog.ActionPermissionResponse: @@ -810,11 +819,28 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { case dialog.PermissionDeny: m.com.App.Permissions.Deny(msg.Permission) } + default: + cmds = append(cmds, uiutil.CmdHandler(msg)) } return tea.Batch(cmds...) } +// openAPIKeyInputDialog opens the API key input dialog. +func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { + if m.dialog.ContainsDialog(dialog.APIKeyInputID) { + m.dialog.BringToFront(dialog.APIKeyInputID) + return nil + } + + apiKeyInputDialog, err := dialog.NewAPIKeyInput(m.com, provider, model, modelType) + if err != nil { + return uiutil.ReportError(err) + } + m.dialog.OpenDialog(apiKeyInputDialog) + return nil +} + func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { var cmds []tea.Cmd diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 12cfd52124fa8fa45488dd34d8556072372f4d8f..1bb6648117e1413950fb8a68dc9a9b3f3b90b89d 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -295,9 +295,14 @@ type Styles struct { // Dialog styles Dialog struct { - Title lipgloss.Style + Title lipgloss.Style + TitleText lipgloss.Style + TitleError lipgloss.Style + TitleAccent lipgloss.Style // View is the main content area style. - View lipgloss.Style + View lipgloss.Style + PrimaryText lipgloss.Style + SecondaryText lipgloss.Style // HelpView is the line that contains the help. HelpView lipgloss.Style Help struct { @@ -1158,7 +1163,12 @@ func DefaultStyles() Styles { // Dialog styles s.Dialog.Title = base.Padding(0, 1).Foreground(primary) + s.Dialog.TitleText = base.Foreground(primary) + s.Dialog.TitleError = base.Foreground(red) + s.Dialog.TitleAccent = base.Foreground(green).Bold(true) s.Dialog.View = base.Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus) + s.Dialog.PrimaryText = base.Padding(0, 1).Foreground(primary) + s.Dialog.SecondaryText = base.Padding(0, 1).Foreground(fgSubtle) s.Dialog.HelpView = base.Padding(0, 1).AlignHorizontal(lipgloss.Left) s.Dialog.Help.ShortKey = base.Foreground(fgMuted) s.Dialog.Help.ShortDesc = base.Foreground(fgSubtle) From fedf05910715b0c6349092cd6afbfa68a41c830d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 13 Jan 2026 09:22:35 -0500 Subject: [PATCH 134/335] fix(ui): dialog: saveKeyAndContinue should return Action --- internal/ui/dialog/api_key_input.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index 57e4df189899426a72e92435044615fa178266c6..bbc3d3746b26e51103ce8545eca5fe3ebaaf977a 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -286,12 +286,12 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg { return ActionChangeAPIKeyState{APIKeyInputStateError} } -func (m *APIKeyInput) saveKeyAndContinue() tea.Msg { +func (m *APIKeyInput) saveKeyAndContinue() Action { cfg := m.com.Config() err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value()) if err != nil { - return uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err)) + return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))} } return ActionSelectModel{ From 5d2c533d9c2cea817506d8a8618c0859b469174b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 9 Jan 2026 11:39:15 -0500 Subject: [PATCH 135/335] feat(ui): dialog: add file picker dialog with image preview --- go.mod | 3 +- go.sum | 6 +- internal/ui/dialog/common.go | 21 ++- internal/ui/dialog/filepicker.go | 292 +++++++++++++++++++++++++++++++ internal/ui/image/image.go | 164 +++++++++++++++++ internal/ui/model/ui.go | 40 +++++ internal/ui/styles/styles.go | 4 + 7 files changed, 521 insertions(+), 9 deletions(-) create mode 100644 internal/ui/dialog/filepicker.go create mode 100644 internal/ui/image/image.go diff --git a/go.mod b/go.mod index b607a70975a383b3c9ba5e2c945fcada2f27c125..1780ea04bf94224cfdbb867a4db61d8724cd3dc9 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( 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/mosaic v0.0.0-20251215102626-e0db08df7383 github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 github.com/charmbracelet/x/term v0.2.2 github.com/denisbrodbeck/machineid v1.0.1 @@ -171,7 +172,7 @@ require ( go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/image v0.27.0 // indirect + golang.org/x/image v0.34.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 70582b7c92f86af89a03d9f9a43382e27235d2ca..335a7c23bd858794e1296d41c4c6c63615b377de 100644 --- a/go.sum +++ b/go.sum @@ -118,6 +118,8 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/ github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= 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/mosaic v0.0.0-20251215102626-e0db08df7383 h1:YpTd2/abobMn/dCRM6Vo+G7JO/VS6RW0Ln3YkVJih8Y= +github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383/go.mod h1:r+fiJS0jb0Z5XKO+1mgKbwbPWzTy8e2dMjBMqa+XqsY= github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc= github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -389,8 +391,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= -golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= +golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= diff --git a/internal/ui/dialog/common.go b/internal/ui/dialog/common.go index 48234281f304208b9e1a30c575fab342ceb5e57a..4c8d166ec7994813229cf0953aa9cc46fd3a27c0 100644 --- a/internal/ui/dialog/common.go +++ b/internal/ui/dialog/common.go @@ -45,12 +45,21 @@ func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, in listStyle := t.Dialog.List.Height(listHeight) listContent := listStyle.Render(list) - content := strings.Join([]string{ - titleStyle.Render(header), - inputStyle.Render(input), - listContent, - helpStyle.Render(help), - }, "\n") + parts := []string{} + if len(header) > 0 { + parts = append(parts, titleStyle.Render(header)) + } + if len(input) > 0 { + parts = append(parts, inputStyle.Render(input)) + } + if len(list) > 0 { + parts = append(parts, listContent) + } + if len(help) > 0 { + parts = append(parts, helpStyle.Render(help)) + } + + content := strings.Join(parts, "\n") return dialogStyle.Render(content) } diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go new file mode 100644 index 0000000000000000000000000000000000000000..6ccf6575a3a0ace7354f6eada3ceb8b1f1d7a904 --- /dev/null +++ b/internal/ui/dialog/filepicker.go @@ -0,0 +1,292 @@ +package dialog + +import ( + "hash/fnv" + "image" + _ "image/jpeg" // register JPEG format + _ "image/png" // register PNG format + "io" + "os" + "strings" + "sync" + + "charm.land/bubbles/v2/filepicker" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/home" + "github.com/charmbracelet/crush/internal/ui/common" + fimage "github.com/charmbracelet/crush/internal/ui/image" + uv "github.com/charmbracelet/ultraviolet" +) + +var ( + transmittedImages = map[uint64]struct{}{} + transmittedMutex sync.RWMutex +) + +// FilePickerID is the identifier for the FilePicker dialog. +const FilePickerID = "filepicker" + +// FilePicker is a dialog that allows users to select files or directories. +type FilePicker struct { + com *common.Common + + width int + imgPrevWidth, imgPrevHeight int + imageCaps *fimage.Capabilities + + img *fimage.Image + fp filepicker.Model + help help.Model + previewingImage bool // indicates if an image is being previewed + + km struct { + Select, + Down, + Up, + Forward, + Backward, + Navigate, + Close key.Binding + } +} + +var _ Dialog = (*FilePicker)(nil) + +// NewFilePicker creates a new [FilePicker] dialog. +func NewFilePicker(com *common.Common) (*FilePicker, Action) { + f := new(FilePicker) + f.com = com + + help := help.New() + help.Styles = com.Styles.DialogHelpStyles() + + f.help = help + + f.km.Select = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "accept"), + ) + f.km.Down = key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("down/j", "move down"), + ) + f.km.Up = key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("up/k", "move up"), + ) + f.km.Forward = key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("right/l", "move forward"), + ) + f.km.Backward = key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("left/h", "move backward"), + ) + f.km.Navigate = key.NewBinding( + key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"), + key.WithHelp("↑↓←→", "navigate"), + ) + f.km.Close = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "close/exit"), + ) + + fp := filepicker.New() + fp.AllowedTypes = []string{".jpg", ".jpeg", ".png"} + fp.ShowPermissions = false + fp.ShowSize = false + fp.AutoHeight = false + fp.Styles = com.Styles.FilePicker + fp.Cursor = "" + fp.CurrentDirectory = f.WorkingDir() + + f.fp = fp + + return f, ActionCmd{f.fp.Init()} +} + +// SetImageCapabilities sets the image capabilities for the [FilePicker]. +func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) { + f.imageCaps = caps +} + +// WorkingDir returns the current working directory of the [FilePicker]. +func (f *FilePicker) WorkingDir() string { + wd := f.com.Config().WorkingDir() + if len(wd) > 0 { + return wd + } + + cwd, err := os.Getwd() + if err != nil { + return home.Dir() + } + + return cwd +} + +// SetWindowSize sets the desired size of the [FilePicker] dialog window. +func (f *FilePicker) SetWindowSize(width, height int) { + f.width = width + f.imgPrevWidth = width/2 - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize() + // Use square preview for simplicity same size as width + f.imgPrevHeight = width/2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize() + f.fp.SetHeight(height) + innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize() + styles := f.com.Styles.FilePicker + styles.File = styles.File.Width(innerWidth) + styles.Directory = styles.Directory.Width(innerWidth) + styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth) + styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth) + f.fp.Styles = styles +} + +// ShortHelp returns the short help key bindings for the [FilePicker] dialog. +func (f *FilePicker) ShortHelp() []key.Binding { + return []key.Binding{ + f.km.Navigate, + f.km.Select, + f.km.Close, + } +} + +// FullHelp returns the full help key bindings for the [FilePicker] dialog. +func (f *FilePicker) FullHelp() [][]key.Binding { + return [][]key.Binding{ + { + f.km.Select, + f.km.Down, + f.km.Up, + f.km.Forward, + }, + { + f.km.Backward, + f.km.Close, + }, + } +} + +// ID returns the identifier of the [FilePicker] dialog. +func (f *FilePicker) ID() string { + return FilePickerID +} + +// Init implements the [Dialog] interface. +func (f *FilePicker) Init() tea.Cmd { + return f.fp.Init() +} + +// HandleMsg updates the [FilePicker] dialog based on the given message. +func (f *FilePicker) HandleMsg(msg tea.Msg) Action { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, f.km.Close): + return ActionClose{} + } + } + + var cmd tea.Cmd + f.fp, cmd = f.fp.Update(msg) + if selFile := f.fp.HighlightedPath(); selFile != "" { + var allowed bool + for _, allowedExt := range f.fp.AllowedTypes { + if strings.HasSuffix(strings.ToLower(selFile), allowedExt) { + allowed = true + break + } + } + + f.previewingImage = allowed + if allowed { + id := uniquePathID(selFile) + + transmittedMutex.RLock() + _, transmitted := transmittedImages[id] + transmittedMutex.RUnlock() + if !transmitted { + img, err := loadImage(selFile) + if err != nil { + f.previewingImage = false + } + + timg, err := fimage.New(selFile, img, f.imgPrevWidth, f.imgPrevHeight) + if err != nil { + f.previewingImage = false + } + + f.img = timg + if err == nil { + cmds = append(cmds, f.img.Transmit()) + transmittedMutex.Lock() + transmittedImages[id] = struct{}{} + transmittedMutex.Unlock() + } + } + } + } + if cmd != nil { + cmds = append(cmds, cmd) + } + + return ActionCmd{tea.Batch(cmds...)} +} + +// Draw renders the [FilePicker] dialog as a string. +func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := f.com.Styles + titleStyle := f.com.Styles.Dialog.Title + dialogStyle := f.com.Styles.Dialog.View + header := common.DialogTitle(t, "Add Image", + max(0, f.width-dialogStyle.GetHorizontalFrameSize()- + titleStyle.GetHorizontalFrameSize())) + files := strings.TrimSpace(f.fp.View()) + filesHeight := f.fp.Height() + imgPreview := t.Dialog.ImagePreview.Render(f.imagePreview()) + view := HeaderInputListHelpView(t, f.width, filesHeight, header, imgPreview, files, f.help.View(f)) + DrawCenter(scr, area, view) + return nil +} + +// imagePreview returns the image preview section of the [FilePicker] dialog. +func (f *FilePicker) imagePreview() string { + if !f.previewingImage || f.img == nil { + // TODO: Cache this? + var sb strings.Builder + for y := 0; y < f.imgPrevHeight; y++ { + for x := 0; x < f.imgPrevWidth; x++ { + sb.WriteRune('╱') + } + if y < f.imgPrevHeight-1 { + sb.WriteRune('\n') + } + } + return sb.String() + } + + return f.img.Render() +} + +func uniquePathID(path string) uint64 { + h := fnv.New64a() + _, _ = io.WriteString(h, path) + return h.Sum64() +} + +func loadImage(path string) (img image.Image, err error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + img, _, err = image.Decode(file) + if err != nil { + return nil, err + } + + return img, nil +} diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go new file mode 100644 index 0000000000000000000000000000000000000000..5de8adfcb9d1bd4e6100243f5c6931bf2f663d35 --- /dev/null +++ b/internal/ui/image/image.go @@ -0,0 +1,164 @@ +package image + +import ( + "bytes" + "fmt" + "hash/fnv" + "image" + "image/color" + "io" + "log/slog" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/ansi/kitty" + "github.com/charmbracelet/x/mosaic" +) + +// Capabilities represents the capabilities of displaying images on the +// terminal. +type Capabilities struct { + // SupportsKittyGraphics indicates whether the terminal supports the Kitty + // graphics protocol. + SupportsKittyGraphics bool +} + +// RequestCapabilities is a [tea.Cmd] that requests the terminal to report +// its image related capabilities to the program. +func RequestCapabilities() tea.Cmd { + return tea.Raw( + // ID 31 is just a random ID used to detect Kitty graphics support. + ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"), + ) +} + +// Encoding represents the encoding format of the image. +type Encoding byte + +// Image encodings. +const ( + EncodingBlocks Encoding = iota + EncodingKitty +) + +// Image represents an image that can be displayed on the terminal. +type Image struct { + id int + img image.Image + cols, rows int // in terminal cells + enc Encoding +} + +// New creates a new [Image] instance with the given unique id, image, and +// dimensions in terminal cells. +func New(id string, img image.Image, cols, rows int) (*Image, error) { + i := new(Image) + h := fnv.New64a() + if _, err := io.WriteString(h, id); err != nil { + return nil, err + } + i.id = int(h.Sum64()) + i.img = img + i.cols = cols + i.rows = rows + return i, nil +} + +// SetEncoding sets the encoding format for the image. +func (i *Image) SetEncoding(enc Encoding) { + i.enc = enc +} + +// Transmit returns a [tea.Cmd] that sends the image data to the terminal. +// This is needed for the [EncodingKitty] protocol so that the terminal can +// cache the image for later rendering. +// +// This should only happen once per image. +func (i *Image) Transmit() tea.Cmd { + if i.enc != EncodingKitty { + return nil + } + + var buf bytes.Buffer + bounds := i.img.Bounds() + imgWidth := bounds.Dx() + imgHeight := bounds.Dy() + + // RGBA is 4 bytes per pixel + imgSize := imgWidth * imgHeight * 4 + + if err := kitty.EncodeGraphics(&buf, i.img, &kitty.Options{ + ID: i.id, + Action: kitty.TransmitAndPut, + Transmission: kitty.Direct, + Format: kitty.RGBA, + Size: imgSize, + Width: imgWidth, + Height: imgHeight, + Columns: i.cols, + Rows: i.rows, + VirtualPlacement: true, + Quite: 2, + }); err != nil { + slog.Error("failed to encode image for kitty graphics", "err", err) + return uiutil.ReportError(fmt.Errorf("failed to encode image")) + } + + return tea.Raw(buf.String()) +} + +// Render renders the image to a string that can be displayed on the terminal. +func (i *Image) Render() string { + // Check cache first + switch i.enc { + case EncodingBlocks: + m := mosaic.New().Width(i.cols).Height(i.rows).Scale(2) + return m.Render(i.img) + case EncodingKitty: + // Build Kitty graphics unicode place holders + var fg color.Color + var extra int + var r, g, b int + extra, r, g, b = i.id>>24&0xff, i.id>>16&0xff, i.id>>8&0xff, i.id&0xff + + if r == 0 && g == 0 { + fg = ansi.IndexedColor(b) + } else { + fg = color.RGBA{ + R: uint8(r), //nolint:gosec + G: uint8(g), //nolint:gosec + B: uint8(b), //nolint:gosec + A: 0xff, + } + } + + fgStyle := ansi.NewStyle().ForegroundColor(fg).String() + + var buf bytes.Buffer + for y := 0; y < i.rows; y++ { + // As an optimization, we only write the fg color sequence id, and + // column-row data once on the first cell. The terminal will handle + // the rest. + buf.WriteString(fgStyle) + buf.WriteRune(kitty.Placeholder) + buf.WriteRune(kitty.Diacritic(y)) + buf.WriteRune(kitty.Diacritic(0)) + if extra > 0 { + buf.WriteRune(kitty.Diacritic(extra)) + } + for x := 1; x < i.cols; x++ { + buf.WriteString(fgStyle) + buf.WriteRune(kitty.Placeholder) + } + if y < i.rows-1 { + buf.WriteByte('\n') + } + } + + return buf.String() + + default: + return "" + } +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 620636ad717100db01e3524c500147f7bd8576ee..0f96e43d2afcb1c41d91376bba8079e86a52b3bb 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -36,6 +36,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/completions" "github.com/charmbracelet/crush/internal/ui/dialog" + timage "github.com/charmbracelet/crush/internal/ui/image" "github.com/charmbracelet/crush/internal/ui/logo" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/uiutil" @@ -144,6 +145,9 @@ type UI struct { // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string + + // imageCaps stores the terminal image capabilities. + imageCaps timage.Capabilities } // New creates a new instance of the [UI] model. @@ -224,6 +228,11 @@ func (m *UI) Init() tea.Cmd { var cmds []tea.Cmd if m.QueryVersion { cmds = append(cmds, tea.RequestTerminalVersion) + // XXX: Right now, we're using the same logic to determine image + // support. Terminals like Apple Terminal and possibly others might + // bleed characters when querying for Kitty graphics via APC escape + // sequences. + cmds = append(cmds, timage.RequestCapabilities()) } return tea.Batch(cmds...) } @@ -426,6 +435,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.completionsOpen { m.completions.SetFiles(msg.Files) } + case uv.KittyGraphicsEvent: + // [timage.RequestCapabilities] sends a Kitty graphics query and this + // captures the response. Any response means the terminal understands + // the protocol. + m.imageCaps.SupportsKittyGraphics = true default: if m.dialog.HasDialogs() { if cmd := m.handleDialogMsg(msg); cmd != nil { @@ -926,6 +940,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } switch { + case key.Matches(msg, m.keyMap.Editor.AddImage): + if cmd := m.openFilesDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case key.Matches(msg, m.keyMap.Editor.SendMessage): value := m.textarea.Value() if before, ok := strings.CutSuffix(value, "\\"); ok { @@ -2032,6 +2051,27 @@ func (m *UI) openSessionsDialog() tea.Cmd { } m.dialog.OpenDialog(dialog) + return nil +} + +// openFilesDialog opens the file picker dialog. +func (m *UI) openFilesDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.FilePickerID) { + // Bring to front + m.dialog.BringToFront(dialog.FilePickerID) + return nil + } + + const desiredFilePickerHeight = 10 + filePicker, action := dialog.NewFilePicker(m.com) + filePicker.SetWindowSize(min(80, m.width-8), desiredFilePickerHeight) + filePicker.SetImageCapabilities(&m.imageCaps) + m.dialog.OpenDialog(filePicker) + + switch action := action.(type) { + case dialog.ActionCmd: + return action.Cmd + } return nil } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 1bb6648117e1413950fb8a68dc9a9b3f3b90b89d..5e84e90e8d4b55f1dd3420e70b8e5a5126b62b3d 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -328,6 +328,8 @@ type Styles struct { ScrollbarTrack lipgloss.Style Commands struct{} + + ImagePreview lipgloss.Style } // Status bar and help @@ -1186,6 +1188,8 @@ func DefaultStyles() Styles { s.Dialog.ScrollbarThumb = base.Foreground(secondary) s.Dialog.ScrollbarTrack = base.Foreground(border) + s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(1) + s.Status.Help = lipgloss.NewStyle().Padding(0, 1) s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!") s.Status.InfoIndicator = s.Status.SuccessIndicator From 7a2769c0a3b51fb0bfe6988c43208eb272f03d67 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 13 Jan 2026 16:26:26 +0100 Subject: [PATCH 136/335] Refactor commands (#1847) --- internal/commands/commands.go | 213 +++++++++++++++ internal/ui/dialog/actions.go | 20 ++ internal/ui/dialog/commands.go | 386 +++++++++++++--------------- internal/ui/dialog/commands_item.go | 39 ++- internal/ui/model/ui.go | 97 ++++++- internal/uicmd/uicmd.go | 1 + 6 files changed, 523 insertions(+), 233 deletions(-) create mode 100644 internal/commands/commands.go diff --git a/internal/commands/commands.go b/internal/commands/commands.go new file mode 100644 index 0000000000000000000000000000000000000000..169b789abd224b774592032c02a5156b91efb3a5 --- /dev/null +++ b/internal/commands/commands.go @@ -0,0 +1,213 @@ +package commands + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/charmbracelet/crush/internal/agent/tools/mcp" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/home" +) + +var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`) + +const ( + userCommandPrefix = "user:" + projectCommandPrefix = "project:" +) + +// Argument represents a command argument with its name and required status. +type Argument struct { + Name string + Required bool +} + +// MCPCustomCommand represents a custom command loaded from an MCP server. +type MCPCustomCommand struct { + ID string + Name string + Client string + Arguments []Argument +} + +// CustomCommand represents a user-defined custom command loaded from markdown files. +type CustomCommand struct { + ID string + Name string + Content string + Arguments []Argument +} + +type commandSource struct { + path string + prefix string +} + +// LoadCustomCommands loads custom commands from multiple sources including +// XDG config directory, home directory, and project directory. +func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) { + return loadAll(buildCommandSources(cfg)) +} + +// LoadMCPCustomCommands loads custom commands from available MCP servers. +func LoadMCPCustomCommands() ([]MCPCustomCommand, error) { + var commands []MCPCustomCommand + for mcpName, prompts := range mcp.Prompts() { + for _, prompt := range prompts { + key := mcpName + ":" + prompt.Name + var args []Argument + for _, arg := range prompt.Arguments { + args = append(args, Argument{Name: arg.Name, Required: arg.Required}) + } + + commands = append(commands, MCPCustomCommand{ + ID: key, + Name: prompt.Name, + Client: mcpName, + Arguments: args, + }) + } + } + return commands, nil +} + +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 loadAll(sources []commandSource) ([]CustomCommand, error) { + var commands []CustomCommand + + for _, source := range sources { + if cmds, err := loadFromSource(source); err == nil { + commands = append(commands, cmds...) + } + } + + return commands, nil +} + +func loadFromSource(source commandSource) ([]CustomCommand, error) { + if err := ensureDir(source.path); err != nil { + return nil, err + } + + var commands []CustomCommand + + 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 := loadCommand(path, source.path, source.prefix) + if err != nil { + return nil // Skip invalid files + } + + commands = append(commands, cmd) + return nil + }) + + return commands, err +} + +func loadCommand(path, baseDir, prefix string) (CustomCommand, error) { + content, err := os.ReadFile(path) + if err != nil { + return CustomCommand{}, err + } + + id := buildCommandID(path, baseDir, prefix) + + return CustomCommand{ + ID: id, + Name: id, + Content: string(content), + Arguments: extractArgNames(string(content)), + }, nil +} + +func extractArgNames(content string) []Argument { + matches := namedArgPattern.FindAllStringSubmatch(content, -1) + if len(matches) == 0 { + return nil + } + + seen := make(map[string]bool) + var args []Argument + + for _, match := range matches { + arg := match[1] + if !seen[arg] { + seen[arg] = true + // for normal custom commands, all args are required + args = append(args, Argument{Name: arg, Required: true}) + } + } + + return args +} + +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 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 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") +} diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index ecf81432410c31a523d221221e00c50d9862b9ac..2fe0513ec56bc70ed3ec5bbe1eb9dde365408cdf 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -3,6 +3,7 @@ package dialog import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" @@ -39,6 +40,8 @@ type ( ActionToggleThinking struct{} ActionExternalEditor struct{} ActionToggleYoloMode struct{} + // ActionInitializeProject is a message to initialize a project. + ActionInitializeProject struct{} ActionSummarize struct { SessionID string } @@ -46,6 +49,23 @@ type ( Permission permission.PermissionRequest Action PermissionAction } + // ActionRunCustomCommand is a message to run a custom command. + ActionRunCustomCommand struct { + CommandID string + // Used when running a user-defined command + Content string + // Used when running a prompt from MCP + Client string + } + // ActionOpenCustomCommandArgumentsDialog is a message to open the custom command arguments dialog. + ActionOpenCustomCommandArgumentsDialog struct { + CommandID string + // Used when running a user-defined command + Content string + // Used when running a prompt from MCP + Client string + Arguments []commands.Argument + } ) // Messages for API key input dialog. diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 8211016b95fb1e71b5cb64699d2d0fd12930ee84..ae9574e2d76b74ccc7465b59a2cafce6f7d9fd0e 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -1,9 +1,7 @@ package dialog import ( - "fmt" "os" - "slices" "strings" "charm.land/bubbles/v2/help" @@ -12,15 +10,11 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" - "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/csync" - "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" - "github.com/charmbracelet/crush/internal/uicmd" - "github.com/charmbracelet/crush/internal/uiutil" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" ) @@ -28,6 +22,20 @@ import ( // CommandsID is the identifier for the commands dialog. const CommandsID = "commands" +// CommandType represents the type of commands being displayed. +type CommandType uint + +// String returns the string representation of the CommandType. +func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] } + +const sidebarCompactModeBreakpoint = 120 + +const ( + SystemCommands CommandType = iota + UserCommands + MCPPrompts +) + // Commands represents a dialog that shows available commands. type Commands struct { com *common.Common @@ -37,39 +45,33 @@ type Commands struct { Next, Previous, Tab, + ShiftTab, Close key.Binding } - sessionID string // can be empty for non-session-specific commands - selected uicmd.CommandType - userCmds []uicmd.Command - mcpPrompts *csync.Slice[uicmd.Command] + sessionID string // can be empty for non-session-specific commands + selected CommandType help help.Model input textinput.Model list *list.FilterableList - width int + windowWidth int + + customCommands []commands.CustomCommand + mcpCustomCommands []commands.MCPCustomCommand } var _ Dialog = (*Commands)(nil) // NewCommands creates a new commands dialog. -func NewCommands(com *common.Common, sessionID string) (*Commands, error) { - commands, err := uicmd.LoadCustomCommandsFromConfig(com.Config()) - if err != nil { - return nil, err - } - - mcpPrompts := csync.NewSlice[uicmd.Command]() - mcpPrompts.SetSlice(uicmd.LoadMCPPrompts()) - +func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpCustomCommands []commands.MCPCustomCommand) (*Commands, error) { c := &Commands{ - com: com, - userCmds: commands, - selected: uicmd.SystemCommands, - mcpPrompts: mcpPrompts, - sessionID: sessionID, + com: com, + selected: SystemCommands, + sessionID: sessionID, + customCommands: customCommands, + mcpCustomCommands: mcpCustomCommands, } help := help.New() @@ -96,7 +98,7 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) { key.WithHelp("↑/↓", "choose"), ) c.keyMap.Next = key.NewBinding( - key.WithKeys("down", "ctrl+n"), + key.WithKeys("down"), key.WithHelp("↓", "next item"), ) c.keyMap.Previous = key.NewBinding( @@ -107,12 +109,16 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) { key.WithKeys("tab"), key.WithHelp("tab", "switch selection"), ) + c.keyMap.ShiftTab = key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "switch selection prev"), + ) closeKey := CloseKey closeKey.SetHelp("esc", "cancel") c.keyMap.Close = closeKey // Set initial commands - c.setCommandType(c.selected) + c.setCommandItems(c.selected) return c, nil } @@ -150,20 +156,28 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action { case key.Matches(msg, c.keyMap.Select): if selectedItem := c.list.SelectedItem(); selectedItem != nil { if item, ok := selectedItem.(*CommandItem); ok && item != nil { - // TODO: Please unravel this mess later and the Command - // Handler design. - if cmd := item.Cmd.Handler(item.Cmd); cmd != nil { // Huh?? - return cmd() - } + return item.Action() } } case key.Matches(msg, c.keyMap.Tab): - if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 { + if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 { c.selected = c.nextCommandType() - c.setCommandType(c.selected) + c.setCommandItems(c.selected) + } + case key.Matches(msg, c.keyMap.ShiftTab): + if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 { + c.selected = c.previousCommandType() + c.setCommandItems(c.selected) } default: var cmd tea.Cmd + for _, item := range c.list.VisibleItems() { + if item, ok := item.(*CommandItem); ok && item != nil { + if msg.String() == item.Shortcut() { + return item.Action() + } + } + } c.input, cmd = c.input.Update(msg) value := c.input.Value() c.list.SetFilter(value) @@ -175,28 +189,18 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action { return nil } -// ReloadMCPPrompts reloads the MCP prompts. -func (c *Commands) ReloadMCPPrompts() tea.Cmd { - c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts()) - // If we're currently viewing MCP prompts, refresh the list - if c.selected == uicmd.MCPPrompts { - c.setCommandType(uicmd.MCPPrompts) - } - return nil -} - // Cursor returns the cursor position relative to the dialog. func (c *Commands) Cursor() *tea.Cursor { return InputCursor(c.com.Styles, c.input.Cursor()) } // commandsRadioView generates the command type selector radio buttons. -func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string { +func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds bool, hasMCPPrompts bool) string { if !hasUserCmds && !hasMCPPrompts { return "" } - selectedFn := func(t uicmd.CommandType) string { + selectedFn := func(t CommandType) string { if t == selected { return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String()) } @@ -204,14 +208,14 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm } parts := []string{ - selectedFn(uicmd.SystemCommands), + selectedFn(SystemCommands), } if hasUserCmds { - parts = append(parts, selectedFn(uicmd.UserCommands)) + parts = append(parts, selectedFn(UserCommands)) } if hasMCPPrompts { - parts = append(parts, selectedFn(uicmd.MCPPrompts)) + parts = append(parts, selectedFn(MCPPrompts)) } return strings.Join(parts, " ") @@ -222,7 +226,12 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := c.com.Styles width := max(0, min(defaultDialogMaxWidth, area.Dx())) height := max(0, min(defaultDialogHeight, area.Dy())) - c.width = width + if area.Dx() != c.windowWidth && c.selected == SystemCommands { + // since some items in the list depend on width (e.g. toggle sidebar command), + // we need to reset the command items when width changes + c.setCommandItems(c.selected) + } + c.windowWidth = area.Dx() innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize() heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + @@ -233,7 +242,7 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { c.list.SetSize(innerWidth, height-heightOffset) c.help.SetWidth(innerWidth) - radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0) + radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpCustomCommands) > 0) titleStyle := t.Dialog.Title dialogStyle := t.Dialog.View.Width(width) headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() @@ -265,99 +274,116 @@ func (c *Commands) FullHelp() [][]key.Binding { } } -func (c *Commands) nextCommandType() uicmd.CommandType { +// nextCommandType returns the next command type in the cycle. +func (c *Commands) nextCommandType() CommandType { switch c.selected { - case uicmd.SystemCommands: - if len(c.userCmds) > 0 { - return uicmd.UserCommands + case SystemCommands: + if len(c.customCommands) > 0 { + return UserCommands } - if c.mcpPrompts.Len() > 0 { - return uicmd.MCPPrompts + if len(c.mcpCustomCommands) > 0 { + return MCPPrompts } fallthrough - case uicmd.UserCommands: - if c.mcpPrompts.Len() > 0 { - return uicmd.MCPPrompts + case UserCommands: + if len(c.mcpCustomCommands) > 0 { + return MCPPrompts } fallthrough - case uicmd.MCPPrompts: - return uicmd.SystemCommands + case MCPPrompts: + return SystemCommands default: - return uicmd.SystemCommands + return SystemCommands } } -func (c *Commands) setCommandType(commandType uicmd.CommandType) { - c.selected = commandType - - var commands []uicmd.Command +// previousCommandType returns the previous command type in the cycle. +func (c *Commands) previousCommandType() CommandType { switch c.selected { - case uicmd.SystemCommands: - commands = c.defaultCommands() - case uicmd.UserCommands: - commands = c.userCmds - case uicmd.MCPPrompts: - commands = slices.Collect(c.mcpPrompts.Seq()) + case SystemCommands: + if len(c.mcpCustomCommands) > 0 { + return MCPPrompts + } + if len(c.customCommands) > 0 { + return UserCommands + } + return SystemCommands + case UserCommands: + return SystemCommands + case MCPPrompts: + if len(c.customCommands) > 0 { + return UserCommands + } + return SystemCommands + default: + return SystemCommands } +} + +// setCommandItems sets the command items based on the specified command type. +func (c *Commands) setCommandItems(commandType CommandType) { + c.selected = commandType commandItems := []list.FilterableItem{} - for _, cmd := range commands { - commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd)) + switch c.selected { + case SystemCommands: + for _, cmd := range c.defaultCommands() { + commandItems = append(commandItems, cmd) + } + case UserCommands: + for _, cmd := range c.customCommands { + var action Action + if len(cmd.Arguments) > 0 { + action = ActionOpenCustomCommandArgumentsDialog{ + CommandID: cmd.ID, + Content: cmd.Content, + Arguments: cmd.Arguments, + } + } else { + action = ActionRunCustomCommand{ + CommandID: cmd.ID, + Content: cmd.Content, + } + } + commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action)) + } + case MCPPrompts: + for _, cmd := range c.mcpCustomCommands { + var action Action + if len(cmd.Arguments) > 0 { + action = ActionOpenCustomCommandArgumentsDialog{ + CommandID: cmd.ID, + Client: cmd.Client, + Arguments: cmd.Arguments, + } + } else { + action = ActionRunCustomCommand{ + CommandID: cmd.ID, + Client: cmd.Client, + } + } + commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.Name, "", action)) + } } c.list.SetItems(commandItems...) - c.list.SetSelected(0) c.list.SetFilter("") c.list.ScrollToTop() c.list.SetSelected(0) c.input.SetValue("") } -// TODO: Rethink this -func (c *Commands) defaultCommands() []uicmd.Command { - commands := []uicmd.Command{ - { - ID: "new_session", - Title: "New Session", - Description: "start a new session", - Shortcut: "ctrl+n", - Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ActionNewSession{}) - }, - }, - { - ID: "switch_session", - Title: "Switch Session", - Description: "Switch to a different session", - Shortcut: "ctrl+s", - Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ActionOpenDialog{SessionsID}) - }, - }, - { - ID: "switch_model", - Title: "Switch Model", - Description: "Switch to a different model", - // FIXME: The shortcut might get updated if enhanced keyboard is supported. - Shortcut: "ctrl+l", - Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ActionOpenDialog{ModelsID}) - }, - }, +// defaultCommands returns the list of default system commands. +func (c *Commands) defaultCommands() []*CommandItem { + commands := []*CommandItem{ + NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}), + NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}), + NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}), } // Only show compact command if there's an active session if c.sessionID != "" { - commands = append(commands, uicmd.Command{ - ID: "Summarize", - Title: "Summarize Session", - Description: "Summarize the current session and create a new one with the summary", - Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ActionSummarize{ - SessionID: c.sessionID, - }) - }, - }) + commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID})) } // Add reasoning toggle for models that support it @@ -374,116 +400,58 @@ func (c *Commands) defaultCommands() []uicmd.Command { if selectedModel.Think { status = "Disable" } - commands = append(commands, uicmd.Command{ - ID: "toggle_thinking", - Title: status + " Thinking Mode", - Description: "Toggle model thinking for reasoning-capable models", - Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ActionToggleThinking{}) - }, - }) + commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{})) } // OpenAI models: reasoning effort dialog if len(model.ReasoningLevels) > 0 { - commands = append(commands, uicmd.Command{ - ID: "select_reasoning_effort", - Title: "Select Reasoning Effort", - Description: "Choose reasoning effort level (low/medium/high)", - Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ActionOpenDialog{ - // TODO: Pass reasoning dialog id - }) - }, - }) + commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{ + // TODO: Pass in the reasoning effort dialog id + })) } } } - // Only show toggle compact mode command if window width is larger than compact breakpoint (90) - // TODO: Get. Rid. Of. Magic. Numbers! - if c.width > 120 && c.sessionID != "" { - commands = append(commands, uicmd.Command{ - ID: "toggle_sidebar", - Title: "Toggle Sidebar", - Description: "Toggle between compact and normal layout", - Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ActionToggleCompactMode{}) - }, - }) + // Only show toggle compact mode command if window width is larger than compact breakpoint (120) + if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" { + commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{})) } if c.sessionID != "" { cfg := c.com.Config() agentCfg := cfg.Agents[config.AgentCoder] model := cfg.GetModelByType(agentCfg.Model) if model != nil && model.SupportsImages { - commands = append(commands, uicmd.Command{ - ID: "file_picker", - Title: "Open File Picker", - Shortcut: "ctrl+f", - Description: "Open file picker", - Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ActionOpenDialog{ - // TODO: Pass file picker dialog id - }) - }, - }) + commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{ + // TODO: Pass in the file picker dialog id + })) } } // Add external editor command if $EDITOR is available // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv if os.Getenv("EDITOR") != "" { - commands = append(commands, uicmd.Command{ - ID: "open_external_editor", - Title: "Open External Editor", - Shortcut: "ctrl+o", - Description: "Open external editor to compose message", - Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ActionExternalEditor{}) - }, - }) + commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{})) } - return append(commands, []uicmd.Command{ - { - ID: "toggle_yolo", - Title: "Toggle Yolo Mode", - Description: "Toggle yolo mode", - Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ActionToggleYoloMode{}) - }, - }, - { - ID: "toggle_help", - Title: "Toggle Help", - Shortcut: "ctrl+g", - Description: "Toggle help", - Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(ActionToggleHelp{}) - }, - }, - { - ID: "init", - Title: "Initialize Project", - Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs), - Handler: func(cmd uicmd.Command) tea.Cmd { - initPrompt, err := agent.InitializePrompt(*c.com.Config()) - if err != nil { - return uiutil.ReportError(err) - } - return uiutil.CmdHandler(chat.SendMsg{ - Text: initPrompt, - }) - }, - }, - { - ID: "quit", - Title: "Quit", - Description: "Quit", - Shortcut: "ctrl+c", - Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(tea.QuitMsg{}) - }, - }, - }...) + return append(commands, + NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}), + NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}), + NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}), + NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}), + ) +} + +// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed. +func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) { + c.customCommands = customCommands + if c.selected == UserCommands { + c.setCommandItems(c.selected) + } +} + +// SetMCPCustomCommands sets the MCP custom commands and refreshes the view if MCP prompts are currently displayed. +func (c *Commands) SetMCPCustomCommands(mcpCustomCommands []commands.MCPCustomCommand) { + c.mcpCustomCommands = mcpCustomCommands + if c.selected == MCPPrompts { + c.setCommandItems(c.selected) + } } diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go index 408fe70865bfb02ce446c57c32c2b3d79bfd8fe5..9a2cf2ceef2be54c6f8d9897d4ddd923fd07b80f 100644 --- a/internal/ui/dialog/commands_item.go +++ b/internal/ui/dialog/commands_item.go @@ -2,37 +2,42 @@ package dialog import ( "github.com/charmbracelet/crush/internal/ui/styles" - "github.com/charmbracelet/crush/internal/uicmd" "github.com/sahilm/fuzzy" ) // CommandItem wraps a uicmd.Command to implement the ListItem interface. type CommandItem struct { - Cmd uicmd.Command - t *styles.Styles - m fuzzy.Match - cache map[int]string - focused bool + id string + title string + shortcut string + action Action + t *styles.Styles + m fuzzy.Match + cache map[int]string + focused bool } var _ ListItem = &CommandItem{} // NewCommandItem creates a new CommandItem. -func NewCommandItem(t *styles.Styles, cmd uicmd.Command) *CommandItem { +func NewCommandItem(t *styles.Styles, id, title, shortcut string, action Action) *CommandItem { return &CommandItem{ - Cmd: cmd, - t: t, + id: id, + t: t, + title: title, + shortcut: shortcut, + action: action, } } // Filter implements ListItem. func (c *CommandItem) Filter() string { - return c.Cmd.Title + return c.title } // ID implements ListItem. func (c *CommandItem) ID() string { - return c.Cmd.ID + return c.id } // SetFocused implements ListItem. @@ -49,7 +54,17 @@ func (c *CommandItem) SetMatch(m fuzzy.Match) { c.m = m } +// Action returns the action associated with the command item. +func (c *CommandItem) Action() Action { + return c.action +} + +// Shortcut returns the shortcut associated with the command item. +func (c *CommandItem) Shortcut() string { + return c.shortcut +} + // Render implements ListItem. func (c *CommandItem) Render(width int) string { - return renderItem(c.t, c.Cmd.Title, c.Cmd.Shortcut, c.focused, width, c.cache, &c.m) + return renderItem(c.t, c.title, c.shortcut, c.focused, width, c.cache, &c.m) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 620636ad717100db01e3524c500147f7bd8576ee..23c7f0d7b8811aa598eb71a0c42a73a2b17e76cf 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "image" + "log/slog" "math/rand" "net/http" "os" @@ -23,6 +24,7 @@ import ( "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/history" @@ -76,7 +78,18 @@ type openEditorMsg struct { Text string } -type cancelTimerExpiredMsg struct{} +type ( + // cancelTimerExpiredMsg is sent when the cancel timer expires. + cancelTimerExpiredMsg struct{} + // userCommandsLoadedMsg is sent when user commands are loaded. + userCommandsLoadedMsg struct { + Commands []commands.CustomCommand + } + // mcpCustomCommandsLoadedMsg is sent when mcp prompts are loaded. + mcpCustomCommandsLoadedMsg struct { + Prompts []commands.MCPCustomCommand + } +) // UI represents the main user interface model. type UI struct { @@ -144,6 +157,10 @@ type UI struct { // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string + + // custom commands & mcp commands + customCommands []commands.CustomCommand + mcpCustomCommands []commands.MCPCustomCommand } // New creates a new instance of the [UI] model. @@ -225,9 +242,37 @@ func (m *UI) Init() tea.Cmd { if m.QueryVersion { cmds = append(cmds, tea.RequestTerminalVersion) } + // load the user commands async + cmds = append(cmds, m.loadCustomCommands()) return tea.Batch(cmds...) } +// loadCustomCommands loads the custom commands asynchronously. +func (m *UI) loadCustomCommands() tea.Cmd { + return func() tea.Msg { + customCommands, err := commands.LoadCustomCommands(m.com.Config()) + if err != nil { + slog.Error("failed to load custom commands", "error", err) + } + return userCommandsLoadedMsg{Commands: customCommands} + } +} + +// loadMCPrompts loads the MCP prompts asynchronously. +func (m *UI) loadMCPrompts() tea.Cmd { + return func() tea.Msg { + prompts, err := commands.LoadMCPCustomCommands() + 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.MCPCustomCommand{} + } + return mcpCustomCommandsLoadedMsg{Prompts: prompts} + } +} + // Update handles updates to the UI model. func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -250,6 +295,29 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } + case userCommandsLoadedMsg: + m.customCommands = msg.Commands + dia := m.dialog.Dialog(dialog.CommandsID) + if dia == nil { + break + } + + commands, ok := dia.(*dialog.Commands) + if ok { + commands.SetCustomCommands(m.customCommands) + } + case mcpCustomCommandsLoadedMsg: + m.mcpCustomCommands = msg.Prompts + dia := m.dialog.Dialog(dialog.CommandsID) + if dia == nil { + break + } + + commands, ok := dia.(*dialog.Commands) + if ok { + commands.SetMCPCustomCommands(m.mcpCustomCommands) + } + case pubsub.Event[message.Message]: // Check if this is a child session message for an agent tool. if m.session == nil { @@ -274,18 +342,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lspStates = app.GetLSPStates() case pubsub.Event[mcp.Event]: m.mcpStates = mcp.GetStates() - if msg.Type == pubsub.UpdatedEvent && m.dialog.ContainsDialog(dialog.CommandsID) { - dia := m.dialog.Dialog(dialog.CommandsID) - if dia == nil { + // check if all mcps are initialized + initialized := true + for _, state := range m.mcpStates { + if state.State == mcp.StateStarting { + initialized = false break } - - commands, ok := dia.(*dialog.Commands) - if ok { - if cmd := commands.ReloadMCPPrompts(); cmd != nil { - cmds = append(cmds, cmd) - } - } + } + if initialized && m.mcpCustomCommands == nil { + cmds = append(cmds, m.loadMCPrompts()) } case pubsub.Event[permission.PermissionRequest]: if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil { @@ -776,6 +842,13 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionQuit: cmds = append(cmds, tea.Quit) + case dialog.ActionInitializeProject: + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session...")) + break + } + cmds = append(cmds, m.initializeProject()) + case dialog.ActionSelectModel: if m.com.App.AgentCoordinator.IsBusy() { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) @@ -2001,7 +2074,7 @@ func (m *UI) openCommandsDialog() tea.Cmd { sessionID = m.session.ID } - commands, err := dialog.NewCommands(m.com, sessionID) + commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpCustomCommands) if err != nil { return uiutil.ReportError(err) } diff --git a/internal/uicmd/uicmd.go b/internal/uicmd/uicmd.go index c571dacd1989c518347e3a773b36d6d5fd2b8878..c2ce2d89d1457459ac84c9e97c6e68b371e042d8 100644 --- a/internal/uicmd/uicmd.go +++ b/internal/uicmd/uicmd.go @@ -1,6 +1,7 @@ // 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 ( From 19c67f7ffc90d1470a4de5644ac78733f4020a42 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 13 Jan 2026 16:58:00 +0100 Subject: [PATCH 137/335] refactor: compact mode (#1850) --- internal/ui/dialog/commands.go | 2 +- internal/ui/model/header.go | 112 +++++++++++++ internal/ui/model/lsp.go | 3 + internal/ui/model/mcp.go | 3 + internal/ui/model/session.go | 11 +- internal/ui/model/sidebar.go | 2 +- internal/ui/model/ui.go | 277 +++++++++++++++++++++++++-------- internal/ui/styles/styles.go | 34 +++- 8 files changed, 366 insertions(+), 78 deletions(-) create mode 100644 internal/ui/model/header.go diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index ae9574e2d76b74ccc7465b59a2cafce6f7d9fd0e..03707a54775992992a36e90e6857b0f55ce3c8e3 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -227,11 +227,11 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { width := max(0, min(defaultDialogMaxWidth, area.Dx())) height := max(0, min(defaultDialogHeight, area.Dy())) if area.Dx() != c.windowWidth && c.selected == SystemCommands { + c.windowWidth = area.Dx() // since some items in the list depend on width (e.g. toggle sidebar command), // we need to reset the command items when width changes c.setCommandItems(c.selected) } - c.windowWidth = area.Dx() innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize() heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + diff --git a/internal/ui/model/header.go b/internal/ui/model/header.go new file mode 100644 index 0000000000000000000000000000000000000000..e01a19143c20e0d3e2c6753b719c28092077ac91 --- /dev/null +++ b/internal/ui/model/header.go @@ -0,0 +1,112 @@ +package model + +import ( + "fmt" + "strings" + + "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/session" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +const ( + headerDiag = "╱" + minHeaderDiags = 3 + leftPadding = 1 + rightPadding = 1 +) + +// renderCompactHeader renders the compact header for the given session. +func renderCompactHeader( + com *common.Common, + session *session.Session, + lspClients *csync.Map[string, *lsp.Client], + detailsOpen bool, + width int, +) string { + if session == nil || session.ID == "" { + return "" + } + + t := com.Styles + + var b strings.Builder + + b.WriteString(t.Header.Charm.Render("Charm™")) + b.WriteString(" ") + b.WriteString(styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary)) + b.WriteString(" ") + + availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags + details := renderHeaderDetails(com, session, lspClients, detailsOpen, availDetailWidth) + + remainingWidth := width - + lipgloss.Width(b.String()) - + lipgloss.Width(details) - + leftPadding - + rightPadding + + if remainingWidth > 0 { + b.WriteString(t.Header.Diagonals.Render( + strings.Repeat(headerDiag, max(minHeaderDiags, remainingWidth)), + )) + b.WriteString(" ") + } + + b.WriteString(details) + + return t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String()) +} + +// renderHeaderDetails renders the details section of the header. +func renderHeaderDetails( + com *common.Common, + session *session.Session, + lspClients *csync.Map[string, *lsp.Client], + detailsOpen bool, + availWidth int, +) string { + t := com.Styles + + var parts []string + + errorCount := 0 + for l := range lspClients.Seq() { + errorCount += l.GetDiagnosticCounts().Error + } + + if errorCount > 0 { + parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount))) + } + + agentCfg := config.Get().Agents[config.AgentCoder] + model := config.Get().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) + + const keystroke = "ctrl+d" + if detailsOpen { + parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" close")) + } else { + parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" open ")) + } + + dot := t.Header.Separator.Render(" • ") + metadata := strings.Join(parts, dot) + metadata = dot + metadata + + const dirTrimLimit = 4 + cfg := com.Config() + cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit) + cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…") + cwd = t.Header.WorkingDir.Render(cwd) + + return cwd + metadata +} diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go index 1f13b5afc3c8a90b6ca14e304636e31fbedddbfc..61e9f75d478ef51daee465ca7eeca109acd6c64b 100644 --- a/internal/ui/model/lsp.go +++ b/internal/ui/model/lsp.go @@ -72,6 +72,9 @@ func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverit // lspList renders a list of LSP clients with their status and diagnostics, // truncating to maxItems if needed. func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string { + if maxItems <= 0 { + return "" + } var renderedLsps []string for _, l := range lsps { var icon string diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go index 4100907d2c58f4238eb080356a069cf9bd0a2da6..40be8619133268edbc53cf2bee863ed89a2af00f 100644 --- a/internal/ui/model/mcp.go +++ b/internal/ui/model/mcp.go @@ -49,6 +49,9 @@ func mcpCounts(t *styles.Styles, counts mcp.Counts) string { // mcpList renders a list of MCP clients with their status and counts, // truncating to maxItems if needed. func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string { + if maxItems <= 0 { + return "" + } var renderedMcps []string for _, m := range mcps { diff --git a/internal/ui/model/session.go b/internal/ui/model/session.go index 065a17ad49b7d14092fc6bb868390e522e5eeaa8..38fd718db9cf2b44eb48538a9debb25870b90a7d 100644 --- a/internal/ui/model/session.go +++ b/internal/ui/model/session.go @@ -169,9 +169,13 @@ func (m *UI) handleFileEvent(file history.File) tea.Cmd { // filesInfo renders the modified files section for the sidebar, showing files // with their addition/deletion counts. -func (m *UI) filesInfo(cwd string, width, maxItems int) string { +func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string { t := m.com.Styles - title := common.Section(t, "Modified Files", width) + + title := t.Subtle.Render("Modified Files") + if isSection { + title = common.Section(t, "Modified Files", width) + } list := t.Subtle.Render("None") if len(m.sessionFiles) > 0 { @@ -184,6 +188,9 @@ func (m *UI) filesInfo(cwd string, width, maxItems int) 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 { + if maxItems <= 0 { + return "" + } var renderedFiles []string filesShown := 0 diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 11d7b73baee60fbf68514ae34fb5aeaf459a16d9..c0e46eb31530bc9b9d4f62fbfb020afdd7abc009 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -133,7 +133,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) { lspSection := m.lspInfo(width, maxLSPs, true) mcpSection := m.mcpInfo(width, maxMCPs, true) - filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles) + filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles, true) uv.NewStyledString( lipgloss.NewStyle(). diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 23c7f0d7b8811aa598eb71a0c42a73a2b17e76cf..6a92f5bb9c7c4f856cd83b38272a63120fea929f 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -53,6 +53,15 @@ const maxAttachmentSize = int64(5 * 1024 * 1024) // Allowed image formats. var allowedImageTypes = []string{".jpg", ".jpeg", ".png"} +// Compact mode breakpoints. +const ( + compactModeWidthBreakpoint = 120 + compactModeHeightBreakpoint = 30 +) + +// Session details panel max height. +const sessionDetailsMaxHeight = 20 + // uiFocusState represents the current focus state of the UI. type uiFocusState uint8 @@ -71,7 +80,6 @@ const ( uiInitialize uiLanding uiChat - uiChatCompact ) type openEditorMsg struct { @@ -161,6 +169,16 @@ type UI struct { // custom commands & mcp commands customCommands []commands.CustomCommand mcpCustomCommands []commands.MCPCustomCommand + + // forceCompactMode tracks whether compact mode is forced by user toggle + forceCompactMode bool + + // isCompact tracks whether we're currently in compact layout mode (either + // by user toggle or auto-switch based on window size) + isCompact bool + + // detailsOpen tracks whether the details panel is open (in compact mode) + detailsOpen bool } // New creates a new instance of the [UI] model. @@ -233,6 +251,9 @@ func New(com *common.Common) *UI { ui.textarea.Placeholder = ui.readyPlaceholder ui.status = status + // Initialize compact mode from config + ui.forceCompactMode = com.Config().Options.TUI.CompactMode + return ui } @@ -284,6 +305,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case loadSessionMsg: m.state = uiChat + if m.forceCompactMode { + m.isCompact = true + } m.session = msg.session m.sessionFiles = msg.files msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID) @@ -370,6 +394,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height + m.handleCompactMode(m.width, m.height) m.updateLayoutAndSize() case tea.KeyboardEnhancementsMsg: m.keyenh = msg @@ -840,6 +865,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { case dialog.ActionToggleHelp: m.status.ToggleHelp() m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionToggleCompactMode: + cmds = append(cmds, m.toggleCompactMode()) + m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionQuit: cmds = append(cmds, tea.Quit) case dialog.ActionInitializeProject: @@ -938,6 +966,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { cmds = append(cmds, cmd) } return true + case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact: + m.detailsOpen = !m.detailsOpen + m.updateLayoutAndSize() + return true } return false } @@ -972,7 +1004,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { case uiInitialize: cmds = append(cmds, m.updateInitializeView(msg)...) return tea.Batch(cmds...) - case uiChat, uiLanding, uiChatCompact: + case uiChat, uiLanding: switch m.focus { case uiFocusEditor: // Handle completions if open. @@ -1070,6 +1102,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } } + // remove the details if they are open when user starts typing + if m.detailsOpen { + m.detailsOpen = false + m.updateLayoutAndSize() + } + ta, cmd := m.textarea.Update(msg) m.textarea = ta cmds = append(cmds, cmd) @@ -1220,28 +1258,26 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { editor.Draw(scr, layout.editor) case uiChat: - m.chat.Draw(scr, layout.main) + if m.isCompact { + header := uv.NewStyledString(m.header) + header.Draw(scr, layout.header) + } else { + m.drawSidebar(scr, layout.sidebar) + } - header := uv.NewStyledString(m.header) - header.Draw(scr, layout.header) - m.drawSidebar(scr, layout.sidebar) + m.chat.Draw(scr, layout.main) - editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx() - layout.sidebar.Dx())) + editorWidth := scr.Bounds().Dx() + if !m.isCompact { + editorWidth -= layout.sidebar.Dx() + } + editor := uv.NewStyledString(m.renderEditorView(editorWidth)) editor.Draw(scr, layout.editor) - case uiChatCompact: - header := uv.NewStyledString(m.header) - header.Draw(scr, layout.header) - - mainView := lipgloss.NewStyle().Width(layout.main.Dx()). - Height(layout.main.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Compact Chat Messages ") - main := uv.NewStyledString(mainView) - main.Draw(scr, layout.main) - - editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx())) - editor.Draw(scr, layout.editor) + // Draw details overlay in compact mode when open + if m.isCompact && m.detailsOpen { + m.drawSessionDetails(scr, layout.sessionDetails) + } } // Add status and help layer @@ -1290,6 +1326,10 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { // Don't show cursor if editor is not visible return nil } + if m.detailsOpen && m.isCompact { + // Don't show cursor if details overlay is open + return nil + } if m.textarea.Focused() { cur := m.textarea.Cursor() @@ -1537,6 +1577,36 @@ func (m *UI) FullHelp() [][]key.Binding { return binds } +// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states. +func (m *UI) toggleCompactMode() tea.Cmd { + m.forceCompactMode = !m.forceCompactMode + + err := m.com.Config().SetCompactMode(m.forceCompactMode) + if err != nil { + return uiutil.ReportError(err) + } + + m.handleCompactMode(m.width, m.height) + m.updateLayoutAndSize() + + return nil +} + +// handleCompactMode updates the UI state based on window size and compact mode setting. +func (m *UI) handleCompactMode(newWidth, newHeight int) { + if m.state == uiChat { + if m.forceCompactMode { + m.isCompact = true + return + } + if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint { + m.isCompact = true + } else { + m.isCompact = false + } + } +} + // updateLayoutAndSize updates the layout and sizes of UI components. func (m *UI) updateLayoutAndSize() { m.layout = m.generateLayout(m.width, m.height) @@ -1558,11 +1628,11 @@ func (m *UI) updateSize() { m.renderHeader(false, m.layout.header.Dx()) case uiChat: - m.renderSidebarLogo(m.layout.sidebar.Dx()) - - case uiChatCompact: - // TODO: set the width and heigh of the chat component - m.renderHeader(true, m.layout.header.Dx()) + if m.isCompact { + m.renderHeader(true, m.layout.header.Dx()) + } else { + m.renderSidebarLogo(m.layout.sidebar.Dx()) + } } } @@ -1579,8 +1649,7 @@ func (m *UI) generateLayout(w, h int) layout { // The sidebar width sidebarWidth := 30 // The header height - // TODO: handle compact - headerHeight := 4 + const landingHeaderHeight = 4 var helpKeyMap help.KeyMap = m if m.status.ShowingAll() { @@ -1619,7 +1688,7 @@ func (m *UI) generateLayout(w, h int) layout { // ------ // help - headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight)) + headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight)) layout.header = headerRect layout.main = mainRect @@ -1633,7 +1702,7 @@ func (m *UI) generateLayout(w, h int) layout { // editor // ------ // help - headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight)) + headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight)) mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) // Remove extra padding from editor (but keep it for header and main) editorRect.Min.X -= 1 @@ -1643,41 +1712,52 @@ func (m *UI) generateLayout(w, h int) layout { layout.editor = editorRect case uiChat: - // Layout - // - // ------|--- - // main | - // ------| side - // editor| - // ---------- - // help - - mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth)) - // Add padding left - sideRect.Min.X += 1 - mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) - mainRect.Max.X -= 1 // Add padding right - // Add bottom margin to main - mainRect.Max.Y -= 1 - layout.sidebar = sideRect - layout.main = mainRect - layout.editor = editorRect - - case uiChatCompact: - // Layout - // - // compact-header - // ------ - // main - // ------ - // editor - // ------ - // help - headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight)) - mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) - layout.header = headerRect - layout.main = mainRect - layout.editor = editorRect + if m.isCompact { + // Layout + // + // compact-header + // ------ + // main + // ------ + // editor + // ------ + // help + const compactHeaderHeight = 1 + headerRect, mainRect := uv.SplitVertical(appRect, uv.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 + // Add one line gap between header and main content + mainRect.Min.Y += 1 + mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + mainRect.Max.X -= 1 // Add padding right + // Add bottom margin to main + mainRect.Max.Y -= 1 + layout.header = headerRect + layout.main = mainRect + layout.editor = editorRect + } else { + // Layout + // + // ------|--- + // main | + // ------| side + // editor| + // ---------- + // help + + mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth)) + // Add padding left + sideRect.Min.X += 1 + mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + mainRect.Max.X -= 1 // Add padding right + // Add bottom margin to main + mainRect.Max.Y -= 1 + layout.sidebar = sideRect + layout.main = mainRect + layout.editor = editorRect + } } if !layout.editor.Empty() { @@ -1711,6 +1791,9 @@ type layout struct { // status is the area for the status view. status uv.Rectangle + + // session details is the area for the session details overlay in compact mode. + sessionDetails uv.Rectangle } func (m *UI) openEditor(value string) tea.Cmd { @@ -1916,8 +1999,11 @@ 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) { - // TODO: handle the compact case differently - m.header = renderLogo(m.com.Styles, compact, width) + 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 @@ -1939,8 +2025,13 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C return uiutil.ReportError(err) } m.state = uiChat - m.session = &newSession - cmds = append(cmds, m.loadSession(newSession.ID)) + if m.forceCompactMode { + m.isCompact = true + } + if newSession.ID != "" { + m.session = &newSession + cmds = append(cmds, m.loadSession(newSession.ID)) + } } // Capture session ID to avoid race with main goroutine updating m.session. @@ -2252,6 +2343,56 @@ func (m *UI) pasteIdx() int { return result + 1 } +// drawSessionDetails draws the session details in compact mode. +func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) { + if m.session == nil { + return + } + + s := m.com.Styles + + width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize() + height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize() + + title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title) + blocks := []string{ + title, + "", + m.modelInfo(width), + "", + } + + detailsHeader := lipgloss.JoinVertical( + lipgloss.Left, + blocks..., + ) + + version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version) + + remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version) + + const maxSectionWidth = 50 + sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces + maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing + + lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false) + mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false) + filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false) + sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection) + uv.NewStyledString( + s.CompactDetails.View. + Width(area.Dx()). + Render( + lipgloss.JoinVertical( + lipgloss.Left, + detailsHeader, + sections, + version, + ), + ), + ).Draw(scr, area) +} + // 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{ diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 1bb6648117e1413950fb8a68dc9a9b3f3b90b89d..442e3f78a449baae2c99868ae9434d69debce40e 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -69,9 +69,22 @@ type Styles struct { TagError lipgloss.Style TagInfo lipgloss.Style - // Headers - HeaderTool lipgloss.Style - HeaderToolNested lipgloss.Style + // Header + Header struct { + Charm lipgloss.Style // Style for "Charm™" label + Diagonals lipgloss.Style // Style for diagonal separators (╱) + Percentage lipgloss.Style // Style for context percentage + Keystroke lipgloss.Style // Style for keystroke hints (e.g., "ctrl+d") + KeystrokeTip lipgloss.Style // Style for keystroke action text (e.g., "open", "close") + WorkingDir lipgloss.Style // Style for current working directory + Separator lipgloss.Style // Style for separator dots (•) + } + + CompactDetails struct { + View lipgloss.Style + Version lipgloss.Style + Title lipgloss.Style + } // Panels PanelMuted lipgloss.Style @@ -995,9 +1008,18 @@ func DefaultStyles() Styles { s.TagError = s.TagBase.Background(redDark) s.TagInfo = s.TagBase.Background(blueLight) - // headers - s.HeaderTool = lipgloss.NewStyle().Foreground(blue) - s.HeaderToolNested = lipgloss.NewStyle().Foreground(fgHalfMuted) + // Compact header styles + s.Header.Charm = base.Foreground(secondary) + s.Header.Diagonals = base.Foreground(primary) + s.Header.Percentage = s.Muted + s.Header.Keystroke = s.Muted + s.Header.KeystrokeTip = s.Subtle + s.Header.WorkingDir = s.Muted + s.Header.Separator = s.Subtle + + s.CompactDetails.Title = s.Base + s.CompactDetails.View = s.Base.Padding(0, 1, 1, 1).Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus) + s.CompactDetails.Version = s.Muted // panels s.PanelMuted = s.Muted.Background(bgBaseLighter) From 77ecb3924f3378c6a494984ba190a48f99a44a56 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 13 Jan 2026 13:35:23 -0500 Subject: [PATCH 138/335] feat(ui): filepicker: add image attachment support with preview --- go.mod | 1 + go.sum | 3 + internal/ui/common/common.go | 23 +++ internal/ui/dialog/actions.go | 57 +++++++ internal/ui/dialog/common.go | 87 +++++++++-- internal/ui/dialog/filepicker.go | 157 ++++++++++--------- internal/ui/image/image.go | 248 +++++++++++++++++++++++-------- internal/ui/model/ui.go | 39 +++-- internal/ui/styles/styles.go | 2 +- 9 files changed, 453 insertions(+), 164 deletions(-) diff --git a/go.mod b/go.mod index 1780ea04bf94224cfdbb867a4db61d8724cd3dc9..67c5089c7dd01fc3e4bf66e60ac50bdbe95b7767 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/charmbracelet/x/term v0.2.2 github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec + github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.1 github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 diff --git a/go.sum b/go.sum index 335a7c23bd858794e1296d41c4c6c63615b377de..b18aab1b57cd3caf93fee69f1c73b565432ae37b 100644 --- a/go.sum +++ b/go.sum @@ -148,6 +148,8 @@ github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4G 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= @@ -391,6 +393,7 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 68d01c77901a98263defa4d28fe4f80af4ac3cfc..21ab903c388adaa1f626bef46f09c3829f927086 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -1,7 +1,9 @@ package common import ( + "fmt" "image" + "os" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/config" @@ -9,6 +11,12 @@ import ( uv "github.com/charmbracelet/ultraviolet" ) +// MaxAttachmentSize defines the maximum allowed size for file attachments (5 MB). +const MaxAttachmentSize = int64(5 * 1024 * 1024) + +// AllowedImageTypes defines the permitted image file types. +var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"} + // Common defines common UI options and configurations. type Common struct { App *app.App @@ -40,3 +48,18 @@ func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle { maxY := minY + height return image.Rect(minX, minY, maxX, maxY) } + +// IsFileTooBig checks if the file at the given path exceeds the specified size +// limit. +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/ui/dialog/actions.go b/internal/ui/dialog/actions.go index ecf81432410c31a523d221221e00c50d9862b9ac..f03e783ad5a0b7c52a90dbc8f1c94dfc65e647af 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -1,11 +1,19 @@ package dialog import ( + "fmt" + "net/http" + "os" + "path/filepath" + tea "charm.land/bubbletea/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/message" "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" ) // ActionClose is a message to close the current dialog. @@ -60,3 +68,52 @@ type ( type ActionCmd struct { Cmd tea.Cmd } + +// ActionFilePickerSelected is a message indicating a file has been selected in +// the file picker dialog. +type ActionFilePickerSelected struct { + Path string +} + +// Cmd returns a command that reads the file at path and sends a +// [message.Attachement] to the program. +func (a ActionFilePickerSelected) Cmd() tea.Cmd { + path := a.Path + if path == "" { + return nil + } + return func() tea.Msg { + isFileLarge, err := common.IsFileTooBig(path, common.MaxAttachmentSize) + if err != nil { + return uiutil.InfoMsg{ + Type: uiutil.InfoTypeError, + Msg: fmt.Sprintf("unable to read the image: %v", err), + } + } + if isFileLarge { + return uiutil.InfoMsg{ + Type: uiutil.InfoTypeError, + Msg: "file too large, max 5MB", + } + } + + content, err := os.ReadFile(path) + if err != nil { + return uiutil.InfoMsg{ + Type: uiutil.InfoTypeError, + Msg: fmt.Sprintf("unable to read the image: %v", err), + } + } + + mimeBufferSize := min(512, len(content)) + mimeType := http.DetectContentType(content[:mimeBufferSize]) + fileName := filepath.Base(path) + + return message.Attachment{ + FilePath: path, + FileName: fileName, + MimeType: mimeType, + Content: content, + } + } +} diff --git a/internal/ui/dialog/common.go b/internal/ui/dialog/common.go index 4c8d166ec7994813229cf0953aa9cc46fd3a27c0..7c812e4223fab44b38a9b4a41099055d737ec4c2 100644 --- a/internal/ui/dialog/common.go +++ b/internal/ui/dialog/common.go @@ -4,6 +4,7 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/styles" ) @@ -34,32 +35,96 @@ func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor { return cur } +// RenderContext is a dialog rendering context that can be used to render +// common dialog layouts. +type RenderContext struct { + // Styles is the styles to use for rendering. + Styles *styles.Styles + // Width is the total width of the dialog including any margins, borders, + // and paddings. + Width int + // Title is the title of the dialog. This will be styled using the default + // dialog title style and prepended to the content parts slice. + Title string + // Parts are the rendered parts of the dialog. + Parts []string + // Help is the help view content. This will be appended to the content parts + // slice using the default dialog help style. + Help string +} + +// NewRenderContext creates a new RenderContext with the provided styles and width. +func NewRenderContext(t *styles.Styles, width int) *RenderContext { + return &RenderContext{ + Styles: t, + Width: width, + Parts: []string{}, + } +} + +// AddPart adds a rendered part to the dialog. +func (rc *RenderContext) AddPart(part string) { + if len(part) > 0 { + rc.Parts = append(rc.Parts, part) + } +} + +// Render renders the dialog using the provided context. +func (rc *RenderContext) Render() string { + titleStyle := rc.Styles.Dialog.Title + dialogStyle := rc.Styles.Dialog.View.Width(rc.Width) + + parts := []string{} + if len(rc.Title) > 0 { + title := common.DialogTitle(rc.Styles, rc.Title, + max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()- + titleStyle.GetHorizontalFrameSize())) + parts = append(parts, titleStyle.Render(title), "") + } + + for i, p := range rc.Parts { + if len(p) > 0 { + parts = append(parts, p) + } + if i < len(rc.Parts)-1 { + parts = append(parts, "") + } + } + + if len(rc.Help) > 0 { + parts = append(parts, "") + helpStyle := rc.Styles.Dialog.HelpView + helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize()) + parts = append(parts, helpStyle.Render(rc.Help)) + } + + content := strings.Join(parts, "\n") + + return dialogStyle.Render(content) +} + // HeaderInputListHelpView generates a view for dialogs with a header, input, // list, and help sections. func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, input, list, help string) string { + rc := NewRenderContext(t, width) + titleStyle := t.Dialog.Title - helpStyle := t.Dialog.HelpView - dialogStyle := t.Dialog.View.Width(width) inputStyle := t.Dialog.InputPrompt - helpStyle = helpStyle.Width(width - dialogStyle.GetHorizontalFrameSize()) listStyle := t.Dialog.List.Height(listHeight) listContent := listStyle.Render(list) - parts := []string{} if len(header) > 0 { - parts = append(parts, titleStyle.Render(header)) + rc.AddPart(titleStyle.Render(header)) } if len(input) > 0 { - parts = append(parts, inputStyle.Render(input)) + rc.AddPart(inputStyle.Render(input)) } if len(list) > 0 { - parts = append(parts, listContent) + rc.AddPart(listContent) } if len(help) > 0 { - parts = append(parts, helpStyle.Render(help)) + rc.Help = help } - content := strings.Join(parts, "\n") - - return dialogStyle.Render(content) + return rc.Render() } diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index 6ccf6575a3a0ace7354f6eada3ceb8b1f1d7a904..e9705f23317c8ad9fab3874cfafafe3bc4d9eb72 100644 --- a/internal/ui/dialog/filepicker.go +++ b/internal/ui/dialog/filepicker.go @@ -1,11 +1,10 @@ package dialog import ( - "hash/fnv" + "fmt" "image" _ "image/jpeg" // register JPEG format _ "image/png" // register PNG format - "io" "os" "strings" "sync" @@ -14,17 +13,13 @@ 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/home" "github.com/charmbracelet/crush/internal/ui/common" fimage "github.com/charmbracelet/crush/internal/ui/image" uv "github.com/charmbracelet/ultraviolet" ) -var ( - transmittedImages = map[uint64]struct{}{} - transmittedMutex sync.RWMutex -) - // FilePickerID is the identifier for the FilePicker dialog. const FilePickerID = "filepicker" @@ -32,11 +27,10 @@ const FilePickerID = "filepicker" type FilePicker struct { com *common.Common - width int + imgEnc fimage.Encoding imgPrevWidth, imgPrevHeight int - imageCaps *fimage.Capabilities + cellSize fimage.CellSize - img *fimage.Image fp filepicker.Model help help.Model previewingImage bool // indicates if an image is being previewed @@ -94,7 +88,7 @@ func NewFilePicker(com *common.Common) (*FilePicker, Action) { ) fp := filepicker.New() - fp.AllowedTypes = []string{".jpg", ".jpeg", ".png"} + fp.AllowedTypes = common.AllowedImageTypes fp.ShowPermissions = false fp.ShowSize = false fp.AutoHeight = false @@ -109,7 +103,12 @@ func NewFilePicker(com *common.Common) (*FilePicker, Action) { // SetImageCapabilities sets the image capabilities for the [FilePicker]. func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) { - f.imageCaps = caps + if caps != nil { + if caps.SupportsKittyGraphics { + f.imgEnc = fimage.EncodingKitty + } + f.cellSize = caps.CellSize() + } } // WorkingDir returns the current working directory of the [FilePicker]. @@ -127,22 +126,6 @@ func (f *FilePicker) WorkingDir() string { return cwd } -// SetWindowSize sets the desired size of the [FilePicker] dialog window. -func (f *FilePicker) SetWindowSize(width, height int) { - f.width = width - f.imgPrevWidth = width/2 - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize() - // Use square preview for simplicity same size as width - f.imgPrevHeight = width/2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize() - f.fp.SetHeight(height) - innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize() - styles := f.com.Styles.FilePicker - styles.File = styles.File.Width(innerWidth) - styles.Directory = styles.Directory.Width(innerWidth) - styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth) - styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth) - f.fp.Styles = styles -} - // ShortHelp returns the short help key bindings for the [FilePicker] dialog. func (f *FilePicker) ShortHelp() []key.Binding { return []key.Binding{ @@ -201,79 +184,107 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action { } f.previewingImage = allowed - if allowed { - id := uniquePathID(selFile) - - transmittedMutex.RLock() - _, transmitted := transmittedImages[id] - transmittedMutex.RUnlock() - if !transmitted { - img, err := loadImage(selFile) - if err != nil { - f.previewingImage = false - } - - timg, err := fimage.New(selFile, img, f.imgPrevWidth, f.imgPrevHeight) - if err != nil { - f.previewingImage = false - } - - f.img = timg - if err == nil { - cmds = append(cmds, f.img.Transmit()) - transmittedMutex.Lock() - transmittedImages[id] = struct{}{} - transmittedMutex.Unlock() - } + if allowed && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) { + img, err := loadImage(selFile) + if err != nil { + f.previewingImage = false } + + cmds = append(cmds, f.imgEnc.Transmit( + selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight)) + f.previewingImage = true } } if cmd != nil { cmds = append(cmds, cmd) } + if didSelect, path := f.fp.DidSelectFile(msg); didSelect { + return ActionFilePickerSelected{Path: path} + } + return ActionCmd{tea.Batch(cmds...)} } +const ( + filePickerMinWidth = 70 + filePickerMinHeight = 10 +) + // Draw renders the [FilePicker] dialog as a string. func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + width := max(0, min(filePickerMinWidth, area.Dx())) + height := max(0, min(10, area.Dy())) + innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize() + imgPrevHeight := filePickerMinHeight*2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize() + imgPrevWidth := innerWidth - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize() + f.imgPrevWidth = imgPrevWidth + f.imgPrevHeight = imgPrevHeight + f.fp.SetHeight(height) + + styles := f.com.Styles.FilePicker + styles.File = styles.File.Width(innerWidth) + styles.Directory = styles.Directory.Width(innerWidth) + styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth) + styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth) + f.fp.Styles = styles + t := f.com.Styles - titleStyle := f.com.Styles.Dialog.Title - dialogStyle := f.com.Styles.Dialog.View - header := common.DialogTitle(t, "Add Image", - max(0, f.width-dialogStyle.GetHorizontalFrameSize()- - titleStyle.GetHorizontalFrameSize())) + rc := NewRenderContext(t, width) + rc.Title = "Add Image" + rc.Help = f.help.View(f) + + imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight)) + rc.AddPart(imgPreview) + files := strings.TrimSpace(f.fp.View()) - filesHeight := f.fp.Height() - imgPreview := t.Dialog.ImagePreview.Render(f.imagePreview()) - view := HeaderInputListHelpView(t, f.width, filesHeight, header, imgPreview, files, f.help.View(f)) + rc.AddPart(files) + + view := rc.Render() + DrawCenter(scr, area, view) return nil } +var ( + imagePreviewCache = map[string]string{} + imagePreviewMutex sync.RWMutex +) + // imagePreview returns the image preview section of the [FilePicker] dialog. -func (f *FilePicker) imagePreview() string { - if !f.previewingImage || f.img == nil { - // TODO: Cache this? +func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string { + if !f.previewingImage { + key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight) + imagePreviewMutex.RLock() + cached, ok := imagePreviewCache[key] + imagePreviewMutex.RUnlock() + if ok { + return cached + } + var sb strings.Builder - for y := 0; y < f.imgPrevHeight; y++ { - for x := 0; x < f.imgPrevWidth; x++ { - sb.WriteRune('╱') + for y := range imgPrevHeight { + for range imgPrevWidth { + sb.WriteRune('█') } - if y < f.imgPrevHeight-1 { + if y < imgPrevHeight-1 { sb.WriteRune('\n') } } + + imagePreviewMutex.Lock() + imagePreviewCache[key] = sb.String() + imagePreviewMutex.Unlock() + return sb.String() } - return f.img.Render() -} + if id := f.fp.HighlightedPath(); id != "" { + r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight) + return r + } -func uniquePathID(path string) uint64 { - h := fnv.New64a() - _, _ = io.WriteString(h, path) - return h.Sum64() + return "" } func loadImage(path string) (img image.Image, err error) { diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index 5de8adfcb9d1bd4e6100243f5c6931bf2f663d35..e7f51239f8fd5ebecec1f5855911fbe459b340ac 100644 --- a/internal/ui/image/image.go +++ b/internal/ui/image/image.go @@ -8,31 +8,67 @@ import ( "image/color" "io" "log/slog" + "strings" + "sync" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/crush/internal/uiutil" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi/kitty" "github.com/charmbracelet/x/mosaic" + "github.com/disintegration/imaging" ) // Capabilities represents the capabilities of displaying images on the // terminal. type Capabilities struct { + // Columns is the number of character columns in the terminal. + Columns int + // Rows is the number of character rows in the terminal. + Rows int + // PixelWidth is the width of the terminal in pixels. + PixelWidth int + // PixelHeight is the height of the terminal in pixels. + PixelHeight int // SupportsKittyGraphics indicates whether the terminal supports the Kitty // graphics protocol. SupportsKittyGraphics bool } +// CellSize returns the size of a single terminal cell in pixels. +func (c Capabilities) CellSize() CellSize { + return CalculateCellSize(c.PixelWidth, c.PixelHeight, c.Columns, c.Rows) +} + +// CalculateCellSize calculates the size of a single terminal cell in pixels +// based on the terminal's pixel dimensions and character dimensions. +func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellSize { + if charWidth == 0 || charHeight == 0 { + return CellSize{} + } + + return CellSize{ + Width: pixelWidth / charWidth, + Height: pixelHeight / charHeight, + } +} + // RequestCapabilities is a [tea.Cmd] that requests the terminal to report // its image related capabilities to the program. func RequestCapabilities() tea.Cmd { return tea.Raw( - // ID 31 is just a random ID used to detect Kitty graphics support. - ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"), + ansi.WindowOp(14) + // Window size in pixels + // ID 31 is just a random ID used to detect Kitty graphics support. + ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"), ) } +// TransmittedMsg is a message indicating that an image has been transmitted to +// the terminal. +type TransmittedMsg struct { + ID string +} + // Encoding represents the encoding format of the image. type Encoding byte @@ -42,87 +78,171 @@ const ( EncodingKitty ) -// Image represents an image that can be displayed on the terminal. -type Image struct { - id int +type imageKey struct { + id string + cols int + rows int +} + +// Hash returns a hash value for the image key. +// This uses FNV-32a for simplicity and speed. +func (k imageKey) Hash() uint32 { + h := fnv.New32a() + _, _ = io.WriteString(h, k.ID()) + return h.Sum32() +} + +// ID returns a unique string representation of the image key. +func (k imageKey) ID() string { + return fmt.Sprintf("%s-%dx%d", k.id, k.cols, k.rows) +} + +// CellSize represents the size of a single terminal cell in pixels. +type CellSize struct { + Width, Height int +} + +type cachedImage struct { img image.Image - cols, rows int // in terminal cells - enc Encoding + cols, rows int } -// New creates a new [Image] instance with the given unique id, image, and -// dimensions in terminal cells. -func New(id string, img image.Image, cols, rows int) (*Image, error) { - i := new(Image) - h := fnv.New64a() - if _, err := io.WriteString(h, id); err != nil { - return nil, err +var ( + cachedImages = map[imageKey]cachedImage{} + cachedMutex sync.RWMutex +) + +// 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 { + if img == nil { + return nil + } + + key := imageKey{id: id, cols: cols, rows: rows} + + cachedMutex.RLock() + cached, ok := cachedImages[key] + cachedMutex.RUnlock() + if ok { + return cached.img } - i.id = int(h.Sum64()) - i.img = img - i.cols = cols - i.rows = rows - return i, nil + + if cs.Width == 0 || cs.Height == 0 { + return img + } + + maxWidth := cols * cs.Width + maxHeight := rows * cs.Height + + img = imaging.Fit(img, maxWidth, maxHeight, imaging.Lanczos) + + cachedMutex.Lock() + cachedImages[key] = cachedImage{ + img: img, + cols: cols, + rows: rows, + } + cachedMutex.Unlock() + + return img } -// SetEncoding sets the encoding format for the image. -func (i *Image) SetEncoding(enc Encoding) { - i.enc = enc +// HasTransmitted checks if the image with the given ID has already been +// transmitted to the terminal. +func HasTransmitted(id string, cols, rows int) bool { + key := imageKey{id: id, cols: cols, rows: rows} + + cachedMutex.RLock() + _, ok := cachedImages[key] + cachedMutex.RUnlock() + return ok } -// Transmit returns a [tea.Cmd] that sends the image data to the terminal. -// This is needed for the [EncodingKitty] protocol so that the terminal can -// cache the image for later rendering. -// -// This should only happen once per image. -func (i *Image) Transmit() tea.Cmd { - if i.enc != EncodingKitty { +// Transmit transmits the image data to the terminal if needed. This is used to +// cache the image on the terminal for later rendering. +func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int) tea.Cmd { + if img == nil { return nil } - var buf bytes.Buffer - bounds := i.img.Bounds() - imgWidth := bounds.Dx() - imgHeight := bounds.Dy() - - // RGBA is 4 bytes per pixel - imgSize := imgWidth * imgHeight * 4 - - if err := kitty.EncodeGraphics(&buf, i.img, &kitty.Options{ - ID: i.id, - Action: kitty.TransmitAndPut, - Transmission: kitty.Direct, - Format: kitty.RGBA, - Size: imgSize, - Width: imgWidth, - Height: imgHeight, - Columns: i.cols, - Rows: i.rows, - VirtualPlacement: true, - Quite: 2, - }); err != nil { - slog.Error("failed to encode image for kitty graphics", "err", err) - return uiutil.ReportError(fmt.Errorf("failed to encode image")) + key := imageKey{id: id, cols: cols, rows: rows} + + cachedMutex.RLock() + _, ok := cachedImages[key] + cachedMutex.RUnlock() + if ok { + return nil } - return tea.Raw(buf.String()) + cmd := func() tea.Msg { + if e != EncodingKitty { + cachedMutex.Lock() + cachedImages[key] = cachedImage{ + img: img, + cols: cols, + rows: rows, + } + cachedMutex.Unlock() + return TransmittedMsg{ID: key.ID()} + } + + var buf bytes.Buffer + img := fitImage(id, img, cs, cols, rows) + bounds := img.Bounds() + imgWidth := bounds.Dx() + imgHeight := bounds.Dy() + + imgID := int(key.Hash()) + if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{ + ID: imgID, + Action: kitty.TransmitAndPut, + Transmission: kitty.Direct, + Format: kitty.RGBA, + ImageWidth: imgWidth, + ImageHeight: imgHeight, + Columns: cols, + Rows: rows, + VirtualPlacement: true, + Quite: 1, + }); err != nil { + slog.Error("failed to encode image for kitty graphics", "err", err) + return uiutil.ReportError(fmt.Errorf("failed to encode image")) + } + + return tea.RawMsg{Msg: buf.String()} + } + + return cmd } -// Render renders the image to a string that can be displayed on the terminal. -func (i *Image) Render() string { - // Check cache first - switch i.enc { +// Render renders the given image within the specified dimensions using the +// specified encoding. +func (e Encoding) Render(id string, cols, rows int) string { + key := imageKey{id: id, cols: cols, rows: rows} + cachedMutex.RLock() + cached, ok := cachedImages[key] + cachedMutex.RUnlock() + if !ok { + return "" + } + + img := cached.img + + switch e { case EncodingBlocks: - m := mosaic.New().Width(i.cols).Height(i.rows).Scale(2) - return m.Render(i.img) + m := mosaic.New().Width(cols).Height(rows).Scale(1) + return strings.TrimSpace(m.Render(img)) case EncodingKitty: // Build Kitty graphics unicode place holders var fg color.Color var extra int var r, g, b int - extra, r, g, b = i.id>>24&0xff, i.id>>16&0xff, i.id>>8&0xff, i.id&0xff + hashedID := key.Hash() + id := int(hashedID) + extra, r, g, b = id>>24&0xff, id>>16&0xff, id>>8&0xff, id&0xff - if r == 0 && g == 0 { + if id <= 255 { fg = ansi.IndexedColor(b) } else { fg = color.RGBA{ @@ -136,7 +256,7 @@ func (i *Image) Render() string { fgStyle := ansi.NewStyle().ForegroundColor(fg).String() var buf bytes.Buffer - for y := 0; y < i.rows; y++ { + for y := range rows { // As an optimization, we only write the fg color sequence id, and // column-row data once on the first cell. The terminal will handle // the rest. @@ -147,11 +267,11 @@ func (i *Image) Render() string { if extra > 0 { buf.WriteRune(kitty.Diacritic(extra)) } - for x := 1; x < i.cols; x++ { + for x := 1; x < cols; x++ { buf.WriteString(fgStyle) buf.WriteRune(kitty.Placeholder) } - if y < i.rows-1 { + if y < rows-1 { buf.WriteByte('\n') } } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 0f96e43d2afcb1c41d91376bba8079e86a52b3bb..649a588056b9386af5568ff30b6db2bde0ba5638 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -46,12 +46,6 @@ import ( "github.com/charmbracelet/x/editor" ) -// Max file size set to 5M. -const maxAttachmentSize = int64(5 * 1024 * 1024) - -// Allowed image formats. -var allowedImageTypes = []string{".jpg", ".jpeg", ".png"} - // uiFocusState represents the current focus state of the UI. type uiFocusState uint8 @@ -146,8 +140,8 @@ type UI struct { // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string - // imageCaps stores the terminal image capabilities. - imageCaps timage.Capabilities + // imgCaps stores the terminal image capabilities. + imgCaps timage.Capabilities } // New creates a new instance of the [UI] model. @@ -314,6 +308,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.updateLayoutAndSize() + // XXX: We need to store cell dimensions for image rendering. + m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height case tea.KeyboardEnhancementsMsg: m.keyenh = msg if msg.SupportsKeyDisambiguation() { @@ -435,11 +431,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.completionsOpen { m.completions.SetFiles(msg.Files) } + case uv.WindowPixelSizeEvent: + // [timage.RequestCapabilities] requests the terminal to send a window + // size event to help determine pixel dimensions. + m.imgCaps.PixelWidth = msg.Width + m.imgCaps.PixelHeight = msg.Height case uv.KittyGraphicsEvent: // [timage.RequestCapabilities] sends a Kitty graphics query and this // captures the response. Any response means the terminal understands // the protocol. - m.imageCaps.SupportsKittyGraphics = true + m.imgCaps.SupportsKittyGraphics = true default: if m.dialog.HasDialogs() { if cmd := m.handleDialogMsg(msg); cmd != nil { @@ -833,6 +834,16 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { case dialog.PermissionDeny: m.com.App.Permissions.Deny(msg.Permission) } + + case dialog.ActionFilePickerSelected: + cmds = append(cmds, tea.Sequence( + msg.Cmd(), + func() tea.Msg { + m.dialog.CloseDialog(dialog.FilePickerID) + return nil + }, + )) + default: cmds = append(cmds, uiutil.CmdHandler(msg)) } @@ -2062,10 +2073,8 @@ func (m *UI) openFilesDialog() tea.Cmd { return nil } - const desiredFilePickerHeight = 10 filePicker, action := dialog.NewFilePicker(m.com) - filePicker.SetWindowSize(min(80, m.width-8), desiredFilePickerHeight) - filePicker.SetImageCapabilities(&m.imageCaps) + filePicker.SetImageCapabilities(&m.imgCaps) m.dialog.OpenDialog(filePicker) switch action := action.(type) { @@ -2138,7 +2147,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { if strings.Count(msg.Content, "\n") > 2 { return func() tea.Msg { content := []byte(msg.Content) - if int64(len(content)) > maxAttachmentSize { + if int64(len(content)) > common.MaxAttachmentSize { return uiutil.ReportWarn("Paste is too big (>5mb)") } name := fmt.Sprintf("paste_%d.txt", m.pasteIdx()) @@ -2165,7 +2174,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { // Check if file has an allowed image extension. isAllowedType := false lowerPath := strings.ToLower(path) - for _, ext := range allowedImageTypes { + for _, ext := range common.AllowedImageTypes { if strings.HasSuffix(lowerPath, ext) { isAllowedType = true break @@ -2181,7 +2190,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { if err != nil { return uiutil.ReportError(err) } - if fileInfo.Size() > maxAttachmentSize { + if fileInfo.Size() > common.MaxAttachmentSize { return uiutil.ReportWarn("File is too big (>5mb)") } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 5e84e90e8d4b55f1dd3420e70b8e5a5126b62b3d..947b514a2ab0dd3f3737912c46288b100e1bc328 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -1188,7 +1188,7 @@ func DefaultStyles() Styles { s.Dialog.ScrollbarThumb = base.Foreground(secondary) s.Dialog.ScrollbarTrack = base.Foreground(border) - s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(1) + s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(0, 1).Foreground(fgSubtle) s.Status.Help = lipgloss.NewStyle().Padding(0, 1) s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!") From 9f5284f56cebf49d8a13d5bbfb294c0499bb820d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 13 Jan 2026 13:50:45 -0500 Subject: [PATCH 139/335] fix(ui): filepicker: defer image preview until after transmission --- internal/ui/dialog/filepicker.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index e9705f23317c8ad9fab3874cfafafe3bc4d9eb72..8bfdbabd12f54911a7da85842152a17f0ade275a 100644 --- a/internal/ui/dialog/filepicker.go +++ b/internal/ui/dialog/filepicker.go @@ -185,14 +185,17 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action { f.previewingImage = allowed if allowed && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) { + f.previewingImage = false img, err := loadImage(selFile) - if err != nil { - f.previewingImage = false + if err == nil { + cmds = append(cmds, tea.Sequence( + f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight), + func() tea.Msg { + f.previewingImage = true + return nil + }, + )) } - - cmds = append(cmds, f.imgEnc.Transmit( - selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight)) - f.previewingImage = true } } if cmd != nil { From f2f63d1dfcd528a8fb64387f38f716cb0ca7378f Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 15 Jan 2026 12:27:10 +0100 Subject: [PATCH 140/335] Refactor Custom Command Arguments Dialog (#1869) --- internal/commands/commands.go | 64 +++-- internal/ui/dialog/actions.go | 24 +- internal/ui/dialog/api_key_input.go | 8 +- internal/ui/dialog/arguments.go | 399 ++++++++++++++++++++++++++++ internal/ui/dialog/commands.go | 106 +++++--- internal/ui/dialog/dialog.go | 27 +- internal/ui/model/ui.go | 133 ++++++++-- internal/ui/styles/styles.go | 20 ++ 8 files changed, 684 insertions(+), 97 deletions(-) create mode 100644 internal/ui/dialog/arguments.go diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 169b789abd224b774592032c02a5156b91efb3a5..b3fd3915182fa293aefc1fe60ec54e5b369fa591 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -1,6 +1,7 @@ package commands import ( + "context" "io/fs" "os" "path/filepath" @@ -19,18 +20,22 @@ const ( projectCommandPrefix = "project:" ) -// Argument represents a command argument with its name and required status. +// Argument represents a command argument with its metadata. type Argument struct { - Name string - Required bool + ID string + Title string + Description string + Required bool } -// MCPCustomCommand represents a custom command loaded from an MCP server. -type MCPCustomCommand struct { - ID string - Name string - Client string - Arguments []Argument +// MCPPrompt represents a custom command loaded from an MCP server. +type MCPPrompt struct { + ID string + Title string + Description string + PromptID string + ClientID string + Arguments []Argument } // CustomCommand represents a user-defined custom command loaded from markdown files. @@ -52,22 +57,32 @@ func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) { return loadAll(buildCommandSources(cfg)) } -// LoadMCPCustomCommands loads custom commands from available MCP servers. -func LoadMCPCustomCommands() ([]MCPCustomCommand, error) { - var commands []MCPCustomCommand +// LoadMCPPrompts loads custom commands from available MCP servers. +func LoadMCPPrompts() ([]MCPPrompt, error) { + var commands []MCPPrompt for mcpName, prompts := range mcp.Prompts() { for _, prompt := range prompts { key := mcpName + ":" + prompt.Name var args []Argument for _, arg := range prompt.Arguments { - args = append(args, Argument{Name: arg.Name, Required: arg.Required}) + title := arg.Title + if title == "" { + title = arg.Name + } + args = append(args, Argument{ + ID: arg.Name, + Title: title, + Description: arg.Description, + Required: arg.Required, + }) } - - commands = append(commands, MCPCustomCommand{ - ID: key, - Name: prompt.Name, - Client: mcpName, - Arguments: args, + commands = append(commands, MCPPrompt{ + ID: key, + Title: prompt.Title, + Description: prompt.Description, + PromptID: prompt.Name, + ClientID: mcpName, + Arguments: args, }) } } @@ -168,7 +183,7 @@ func extractArgNames(content string) []Argument { if !seen[arg] { seen[arg] = true // for normal custom commands, all args are required - args = append(args, Argument{Name: arg, Required: true}) + args = append(args, Argument{ID: arg, Title: arg, Required: true}) } } @@ -211,3 +226,12 @@ func ensureDir(path string) error { func isMarkdownFile(name string) bool { return strings.HasSuffix(strings.ToLower(name), ".md") } + +func GetMCPPrompt(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) + if err != nil { + return "", err + } + return strings.Join(result, " "), nil +} diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 2fe0513ec56bc70ed3ec5bbe1eb9dde365408cdf..81911f9919be6c94ac158052b4a4e9b2236342a0 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -51,20 +51,18 @@ type ( } // ActionRunCustomCommand is a message to run a custom command. ActionRunCustomCommand struct { - CommandID string - // Used when running a user-defined command - Content string - // Used when running a prompt from MCP - Client string - } - // ActionOpenCustomCommandArgumentsDialog is a message to open the custom command arguments dialog. - ActionOpenCustomCommandArgumentsDialog struct { - CommandID string - // Used when running a user-defined command - Content string - // Used when running a prompt from MCP - Client string + Content string Arguments []commands.Argument + Args map[string]string // Actual argument values + } + // ActionRunMCPPrompt is a message to run a custom command. + ActionRunMCPPrompt struct { + Title string + Description string + PromptID string + ClientID string + Arguments []commands.Argument + Args map[string]string // Actual argument values } ) diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index bbc3d3746b26e51103ce8545eca5fe3ebaaf977a..e28dea2b823143d176d796c8775e8024df61d0bb 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -95,7 +95,7 @@ func (m *APIKeyInput) ID() string { return APIKeyInputID } -// Update implements tea.Model. +// HandleMsg implements [Dialog]. func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { case ActionChangeAPIKeyState: @@ -149,7 +149,7 @@ func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action { return nil } -// View implements tea.Model. +// Draw implements [Dialog]. func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := m.com.Styles @@ -239,8 +239,8 @@ func (m *APIKeyInput) inputView() string { } // Cursor returns the cursor position relative to the dialog. -func (c *APIKeyInput) Cursor() *tea.Cursor { - return InputCursor(c.com.Styles, c.input.Cursor()) +func (m *APIKeyInput) Cursor() *tea.Cursor { + return InputCursor(m.com.Styles, m.input.Cursor()) } // FullHelp returns the full help view. diff --git a/internal/ui/dialog/arguments.go b/internal/ui/dialog/arguments.go new file mode 100644 index 0000000000000000000000000000000000000000..c016b7de6ec77e6e333d2b0f18ae5930ba0912fc --- /dev/null +++ b/internal/ui/dialog/arguments.go @@ -0,0 +1,399 @@ +package dialog + +import ( + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/charmbracelet/crush/internal/commands" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" +) + +// ArgumentsID is the identifier for the arguments dialog. +const ArgumentsID = "arguments" + +// Dialog sizing for arguments. +const ( + maxInputWidth = 120 + minInputWidth = 30 + maxViewportHeight = 20 + argumentsFieldHeight = 3 // label + input + spacing per field +) + +// Arguments represents a dialog for collecting command arguments. +type Arguments struct { + com *common.Common + title string + arguments []commands.Argument + inputs []textinput.Model + focused int + spinner spinner.Model + loading bool + + description string + resultAction Action + + help help.Model + keyMap struct { + Confirm, + Next, + Previous, + ScrollUp, + ScrollDown, + Close key.Binding + } + + viewport viewport.Model +} + +var _ Dialog = (*Arguments)(nil) + +// NewArguments creates a new arguments dialog. +func NewArguments(com *common.Common, title, description string, arguments []commands.Argument, resultAction Action) *Arguments { + a := &Arguments{ + com: com, + title: title, + description: description, + arguments: arguments, + resultAction: resultAction, + } + + a.help = help.New() + a.help.Styles = com.Styles.DialogHelpStyles() + + a.keyMap.Confirm = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ) + a.keyMap.Next = key.NewBinding( + key.WithKeys("down", "tab"), + key.WithHelp("↓/tab", "next"), + ) + a.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "shift+tab"), + key.WithHelp("↑/shift+tab", "previous"), + ) + a.keyMap.Close = CloseKey + + // Create input fields for each argument. + a.inputs = make([]textinput.Model, len(arguments)) + for i, arg := range arguments { + input := textinput.New() + input.SetVirtualCursor(false) + input.SetStyles(com.Styles.TextInput) + input.Prompt = "> " + // Use description as placeholder if available, otherwise title + if arg.Description != "" { + input.Placeholder = arg.Description + } else { + input.Placeholder = arg.Title + } + + if i == 0 { + input.Focus() + } else { + input.Blur() + } + + a.inputs[i] = input + } + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = com.Styles.Dialog.Spinner + a.spinner = s + + return a +} + +// ID implements Dialog. +func (a *Arguments) ID() string { + return ArgumentsID +} + +// focusInput changes focus to a new input by index with wrap-around. +func (a *Arguments) focusInput(newIndex int) { + a.inputs[a.focused].Blur() + + // Wrap around: Go's modulo can return negative, so add len first. + n := len(a.inputs) + a.focused = ((newIndex % n) + n) % n + + a.inputs[a.focused].Focus() + + // Ensure the newly focused field is visible in the viewport + a.ensureFieldVisible(a.focused) +} + +// isFieldVisible checks if a field at the given index is visible in the viewport. +func (a *Arguments) isFieldVisible(fieldIndex int) bool { + fieldStart := fieldIndex * argumentsFieldHeight + fieldEnd := fieldStart + argumentsFieldHeight - 1 + viewportTop := a.viewport.YOffset() + viewportBottom := viewportTop + a.viewport.Height() - 1 + + return fieldStart >= viewportTop && fieldEnd <= viewportBottom +} + +// ensureFieldVisible scrolls the viewport to make the field visible. +func (a *Arguments) ensureFieldVisible(fieldIndex int) { + if a.isFieldVisible(fieldIndex) { + return + } + + fieldStart := fieldIndex * argumentsFieldHeight + fieldEnd := fieldStart + argumentsFieldHeight - 1 + viewportTop := a.viewport.YOffset() + viewportHeight := a.viewport.Height() + + // If field is above viewport, scroll up to show it at top + if fieldStart < viewportTop { + a.viewport.SetYOffset(fieldStart) + return + } + + // If field is below viewport, scroll down to show it at bottom + if fieldEnd > viewportTop+viewportHeight-1 { + a.viewport.SetYOffset(fieldEnd - viewportHeight + 1) + } +} + +// findVisibleFieldByOffset returns the field index closest to the given viewport offset. +func (a *Arguments) findVisibleFieldByOffset(fromTop bool) int { + offset := a.viewport.YOffset() + if !fromTop { + offset += a.viewport.Height() - 1 + } + + fieldIndex := offset / argumentsFieldHeight + if fieldIndex >= len(a.inputs) { + return len(a.inputs) - 1 + } + return fieldIndex +} + +// HandleMsg implements Dialog. +func (a *Arguments) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case spinner.TickMsg: + if a.loading { + var cmd tea.Cmd + a.spinner, cmd = a.spinner.Update(msg) + return ActionCmd{Cmd: cmd} + } + case tea.KeyPressMsg: + switch { + case key.Matches(msg, a.keyMap.Close): + return ActionClose{} + case key.Matches(msg, a.keyMap.Confirm): + // If we're on the last input or there's only one input, submit. + if a.focused == len(a.inputs)-1 || len(a.inputs) == 1 { + args := make(map[string]string) + var warning tea.Cmd + 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.") + break + } + } + if warning != nil { + return ActionCmd{Cmd: warning} + } + + switch action := a.resultAction.(type) { + case ActionRunCustomCommand: + action.Args = args + return action + case ActionRunMCPPrompt: + action.Args = args + return action + } + } + a.focusInput(a.focused + 1) + case key.Matches(msg, a.keyMap.Next): + a.focusInput(a.focused + 1) + case key.Matches(msg, a.keyMap.Previous): + a.focusInput(a.focused - 1) + default: + var cmd tea.Cmd + a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg) + return ActionCmd{Cmd: cmd} + } + case tea.MouseWheelMsg: + a.viewport, _ = a.viewport.Update(msg) + // If focused field scrolled out of view, focus the visible field + if !a.isFieldVisible(a.focused) { + a.focusInput(a.findVisibleFieldByOffset(msg.Button == tea.MouseWheelDown)) + } + case tea.PasteMsg: + var cmd tea.Cmd + a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg) + return ActionCmd{Cmd: cmd} + } + return nil +} + +// Cursor returns the cursor position relative to the dialog. +// we pass the description height to offset the cursor correctly. +func (a *Arguments) Cursor(descriptionHeight int) *tea.Cursor { + cursor := InputCursor(a.com.Styles, a.inputs[a.focused].Cursor()) + if cursor == nil { + return nil + } + cursor.Y += descriptionHeight + a.focused*argumentsFieldHeight - a.viewport.YOffset() + 1 + return cursor +} + +// Draw implements Dialog. +func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + s := a.com.Styles + + dialogContentStyle := s.Dialog.Arguments.Content + possibleWidth := area.Dx() - s.Dialog.View.GetHorizontalFrameSize() - dialogContentStyle.GetHorizontalFrameSize() + // Build fields with label and input. + caser := cases.Title(language.English) + + var fields []string + for i, arg := range a.arguments { + isFocused := i == a.focused + + // Try to pretty up the title for the label. + title := strings.ReplaceAll(arg.Title, "_", " ") + title = strings.ReplaceAll(title, "-", " ") + titleParts := strings.Fields(title) + for i, part := range titleParts { + titleParts[i] = caser.String(strings.ToLower(part)) + } + labelText := strings.Join(titleParts, " ") + + markRequiredStyle := s.Dialog.Arguments.InputRequiredMarkBlurred + + labelStyle := s.Dialog.Arguments.InputLabelBlurred + if isFocused { + labelStyle = s.Dialog.Arguments.InputLabelFocused + markRequiredStyle = s.Dialog.Arguments.InputRequiredMarkFocused + } + if arg.Required { + labelText += markRequiredStyle.String() + } + label := labelStyle.Render(labelText) + + labelWidth := lipgloss.Width(labelText) + placeholderWidth := lipgloss.Width(a.inputs[i].Placeholder) + + inputWidth := max(placeholderWidth, labelWidth, minInputWidth) + inputWidth = min(inputWidth, min(possibleWidth, maxInputWidth)) + a.inputs[i].SetWidth(inputWidth) + + inputLine := a.inputs[i].View() + + field := lipgloss.JoinVertical(lipgloss.Left, label, inputLine, "") + fields = append(fields, field) + } + + renderedFields := lipgloss.JoinVertical(lipgloss.Left, fields...) + + // Anchor width to the longest field, capped at maxInputWidth. + const scrollbarWidth = 1 + width := lipgloss.Width(renderedFields) + height := lipgloss.Height(renderedFields) + + // Use standard header + titleStyle := s.Dialog.Title + + titleText := a.title + if titleText == "" { + titleText = "Arguments" + } + + header := common.DialogTitle(s, titleText, width) + + // Add description if available. + var description string + if a.description != "" { + descStyle := s.Dialog.Arguments.Description.Width(width) + description = descStyle.Render(a.description) + } + + helpView := s.Dialog.HelpView.Width(width).Render(a.help.View(a)) + if a.loading { + helpView = s.Dialog.HelpView.Width(width).Render(a.spinner.View() + " Generating Prompt...") + } + + availableHeight := area.Dy() - s.Dialog.View.GetVerticalFrameSize() - dialogContentStyle.GetVerticalFrameSize() - lipgloss.Height(header) - lipgloss.Height(description) - lipgloss.Height(helpView) - 2 // extra spacing + viewportHeight := min(height, maxViewportHeight, availableHeight) + + a.viewport.SetWidth(width) // -1 for scrollbar + a.viewport.SetHeight(viewportHeight) + a.viewport.SetContent(renderedFields) + + scrollbar := common.Scrollbar(s, viewportHeight, a.viewport.TotalLineCount(), viewportHeight, a.viewport.YOffset()) + content := a.viewport.View() + if scrollbar != "" { + content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar) + } + contentParts := []string{} + if description != "" { + contentParts = append(contentParts, description) + } + contentParts = append(contentParts, content) + + view := lipgloss.JoinVertical( + lipgloss.Left, + titleStyle.Render(header), + dialogContentStyle.Render(lipgloss.JoinVertical(lipgloss.Left, contentParts...)), + helpView, + ) + + dialog := s.Dialog.View.Render(view) + + descriptionHeight := 0 + if a.description != "" { + descriptionHeight = lipgloss.Height(description) + } + cur := a.Cursor(descriptionHeight) + + DrawCenterCursor(scr, area, dialog, cur) + return cur +} + +// StartLoading implements [LoadingDialog]. +func (a *Arguments) StartLoading() tea.Cmd { + if a.loading { + return nil + } + a.loading = true + return a.spinner.Tick +} + +// StopLoading implements [LoadingDialog]. +func (a *Arguments) StopLoading() { + a.loading = false +} + +// ShortHelp implements help.KeyMap. +func (a *Arguments) ShortHelp() []key.Binding { + return []key.Binding{ + a.keyMap.Confirm, + a.keyMap.Next, + a.keyMap.Close, + } +} + +// FullHelp implements help.KeyMap. +func (a *Arguments) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {a.keyMap.Confirm, a.keyMap.Next, a.keyMap.Previous}, + {a.keyMap.Close}, + } +} diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 03707a54775992992a36e90e6857b0f55ce3c8e3..a6861a5c87707d7c0717ec4d3c50c1d995a528af 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -6,6 +6,7 @@ import ( "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -52,26 +53,29 @@ type Commands struct { sessionID string // can be empty for non-session-specific commands selected CommandType + spinner spinner.Model + loading bool + help help.Model input textinput.Model list *list.FilterableList windowWidth int - customCommands []commands.CustomCommand - mcpCustomCommands []commands.MCPCustomCommand + customCommands []commands.CustomCommand + mcpPrompts []commands.MCPPrompt } var _ Dialog = (*Commands)(nil) // NewCommands creates a new commands dialog. -func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpCustomCommands []commands.MCPCustomCommand) (*Commands, error) { +func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) { c := &Commands{ - com: com, - selected: SystemCommands, - sessionID: sessionID, - customCommands: customCommands, - mcpCustomCommands: mcpCustomCommands, + com: com, + selected: SystemCommands, + sessionID: sessionID, + customCommands: customCommands, + mcpPrompts: mcpPrompts, } help := help.New() @@ -120,6 +124,11 @@ func NewCommands(com *common.Common, sessionID string, customCommands []commands // Set initial commands c.setCommandItems(c.selected) + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = com.Styles.Dialog.Spinner + c.spinner = s + return c, nil } @@ -128,9 +137,15 @@ func (c *Commands) ID() string { return CommandsID } -// HandleMsg implements Dialog. +// HandleMsg implements [Dialog]. func (c *Commands) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { + case spinner.TickMsg: + if c.loading { + var cmd tea.Cmd + c.spinner, cmd = c.spinner.Update(msg) + return ActionCmd{Cmd: cmd} + } case tea.KeyPressMsg: switch { case key.Matches(msg, c.keyMap.Close): @@ -160,12 +175,12 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action { } } case key.Matches(msg, c.keyMap.Tab): - if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 { + if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 { c.selected = c.nextCommandType() c.setCommandItems(c.selected) } case key.Matches(msg, c.keyMap.ShiftTab): - if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 { + if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 { c.selected = c.previousCommandType() c.setCommandItems(c.selected) } @@ -242,12 +257,16 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { c.list.SetSize(innerWidth, height-heightOffset) c.help.SetWidth(innerWidth) - radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpCustomCommands) > 0) + radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0) titleStyle := t.Dialog.Title dialogStyle := t.Dialog.View.Width(width) headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() helpView := ansi.Truncate(c.help.View(c), innerWidth, "") header := common.DialogTitle(t, "Commands", width-headerOffset) + radio + + if c.loading { + helpView = t.Dialog.HelpView.Width(width).Render(c.spinner.View() + " Generating Prompt...") + } view := HeaderInputListHelpView(t, width, c.list.Height(), header, c.input.View(), c.list.Render(), helpView) @@ -281,12 +300,12 @@ func (c *Commands) nextCommandType() CommandType { if len(c.customCommands) > 0 { return UserCommands } - if len(c.mcpCustomCommands) > 0 { + if len(c.mcpPrompts) > 0 { return MCPPrompts } fallthrough case UserCommands: - if len(c.mcpCustomCommands) > 0 { + if len(c.mcpPrompts) > 0 { return MCPPrompts } fallthrough @@ -301,7 +320,7 @@ func (c *Commands) nextCommandType() CommandType { func (c *Commands) previousCommandType() CommandType { switch c.selected { case SystemCommands: - if len(c.mcpCustomCommands) > 0 { + if len(c.mcpPrompts) > 0 { return MCPPrompts } if len(c.customCommands) > 0 { @@ -332,37 +351,22 @@ func (c *Commands) setCommandItems(commandType CommandType) { } case UserCommands: for _, cmd := range c.customCommands { - var action Action - if len(cmd.Arguments) > 0 { - action = ActionOpenCustomCommandArgumentsDialog{ - CommandID: cmd.ID, - Content: cmd.Content, - Arguments: cmd.Arguments, - } - } else { - action = ActionRunCustomCommand{ - CommandID: cmd.ID, - Content: cmd.Content, - } + action := ActionRunCustomCommand{ + Content: cmd.Content, + Arguments: cmd.Arguments, } commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action)) } case MCPPrompts: - for _, cmd := range c.mcpCustomCommands { - var action Action - if len(cmd.Arguments) > 0 { - action = ActionOpenCustomCommandArgumentsDialog{ - CommandID: cmd.ID, - Client: cmd.Client, - Arguments: cmd.Arguments, - } - } else { - action = ActionRunCustomCommand{ - CommandID: cmd.ID, - Client: cmd.Client, - } + for _, cmd := range c.mcpPrompts { + action := ActionRunMCPPrompt{ + Title: cmd.Title, + Description: cmd.Description, + PromptID: cmd.PromptID, + ClientID: cmd.ClientID, + Arguments: cmd.Arguments, } - commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.Name, "", action)) + commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action)) } } @@ -448,10 +452,24 @@ func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) { } } -// SetMCPCustomCommands sets the MCP custom commands and refreshes the view if MCP prompts are currently displayed. -func (c *Commands) SetMCPCustomCommands(mcpCustomCommands []commands.MCPCustomCommand) { - c.mcpCustomCommands = mcpCustomCommands +// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed. +func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) { + c.mcpPrompts = mcpPrompts if c.selected == MCPPrompts { c.setCommandItems(c.selected) } } + +// StartLoading implements [LoadingDialog]. +func (a *Commands) StartLoading() tea.Cmd { + if a.loading { + return nil + } + a.loading = true + return a.spinner.Tick +} + +// StopLoading implements [LoadingDialog]. +func (a *Commands) StopLoading() { + a.loading = false +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 68eb313d4ec83cf8d098fcfccb5ebf27de8bd0d1..7a3db40128fb1e5543a94a93faa4ae9aeec5f947 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -27,7 +27,7 @@ var CloseKey = key.NewBinding( ) // Action represents an action taken in a dialog after handling a message. -type Action interface{} +type Action any // Dialog is a component that can be displayed on top of the UI. type Dialog interface { @@ -41,6 +41,12 @@ type Dialog interface { Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor } +// LoadingDialog is a dialog that can show a loading state. +type LoadingDialog interface { + StartLoading() tea.Cmd + StopLoading() +} + // Overlay manages multiple dialogs as an overlay. type Overlay struct { dialogs []Dialog @@ -136,6 +142,25 @@ func (d *Overlay) Update(msg tea.Msg) tea.Msg { return dialog.HandleMsg(msg) } +// StartLoading starts the loading state for the front dialog if it +// implements [LoadingDialog]. +func (d *Overlay) StartLoading() tea.Cmd { + dialog := d.DialogLast() + if ld, ok := dialog.(LoadingDialog); ok { + return ld.StartLoading() + } + return nil +} + +// StopLoading stops the loading state for the front dialog if it +// implements [LoadingDialog]. +func (d *Overlay) StopLoading() { + dialog := d.DialogLast() + if ld, ok := dialog.(LoadingDialog); ok { + ld.StopLoading() + } +} + // DrawCenterCursor draws the given string view centered in the screen area and // adjusts the cursor position accordingly. func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 6a92f5bb9c7c4f856cd83b38272a63120fea929f..c3c6edebbccab27b1072b9376e3c5169d3137894 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -18,6 +18,7 @@ import ( "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -93,10 +94,19 @@ type ( userCommandsLoadedMsg struct { Commands []commands.CustomCommand } - // mcpCustomCommandsLoadedMsg is sent when mcp prompts are loaded. - mcpCustomCommandsLoadedMsg struct { - Prompts []commands.MCPCustomCommand + // mcpPromptsLoadedMsg is sent when mcp prompts are loaded. + mcpPromptsLoadedMsg struct { + Prompts []commands.MCPPrompt } + // sendMessageMsg is sent to send a message. + // currently only used for mcp prompts. + sendMessageMsg struct { + Content string + Attachments []message.Attachment + } + + // closeDialogMsg is sent to close the current dialog. + closeDialogMsg struct{} ) // UI represents the main user interface model. @@ -167,8 +177,8 @@ type UI struct { sidebarLogo string // custom commands & mcp commands - customCommands []commands.CustomCommand - mcpCustomCommands []commands.MCPCustomCommand + customCommands []commands.CustomCommand + mcpPrompts []commands.MCPPrompt // forceCompactMode tracks whether compact mode is forced by user toggle forceCompactMode bool @@ -282,15 +292,15 @@ 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.LoadMCPCustomCommands() + 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.MCPCustomCommand{} + prompts = []commands.MCPPrompt{} } - return mcpCustomCommandsLoadedMsg{Prompts: prompts} + return mcpPromptsLoadedMsg{Prompts: prompts} } } @@ -319,6 +329,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } + case sendMessageMsg: + cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...)) + case userCommandsLoadedMsg: m.customCommands = msg.Commands dia := m.dialog.Dialog(dialog.CommandsID) @@ -330,8 +343,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if ok { commands.SetCustomCommands(m.customCommands) } - case mcpCustomCommandsLoadedMsg: - m.mcpCustomCommands = msg.Prompts + case mcpPromptsLoadedMsg: + m.mcpPrompts = msg.Prompts dia := m.dialog.Dialog(dialog.CommandsID) if dia == nil { break @@ -339,9 +352,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { commands, ok := dia.(*dialog.Commands) if ok { - commands.SetMCPCustomCommands(m.mcpCustomCommands) + commands.SetMCPPrompts(m.mcpPrompts) } + case closeDialogMsg: + m.dialog.CloseFrontDialog() + case pubsub.Event[message.Message]: // Check if this is a child session message for an agent tool. if m.session == nil { @@ -374,7 +390,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } } - if initialized && m.mcpCustomCommands == nil { + if initialized && m.mcpPrompts == nil { cmds = append(cmds, m.loadMCPrompts()) } case pubsub.Event[permission.PermissionRequest]: @@ -492,6 +508,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } } + case spinner.TickMsg: + if m.dialog.HasDialogs() { + // route to dialog + if cmd := m.handleDialogMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } + } + case tea.KeyPressMsg: if cmd := m.handleKeyPressMsg(msg); cmd != nil { cmds = append(cmds, cmd) @@ -645,6 +669,11 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) { // if the message is a tool result it will update the corresponding tool call message func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { var cmds []tea.Cmd + existing := m.chat.MessageItem(msg.ID) + if existing != nil { + // message already exists, skip + return nil + } switch msg.Role { case message.User, message.Assistant: items := chat.ExtractMessageItems(m.com.Styles, &msg, nil) @@ -920,6 +949,44 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { case dialog.PermissionDeny: m.com.App.Permissions.Deny(msg.Permission) } + + case dialog.ActionRunCustomCommand: + if len(msg.Arguments) > 0 && msg.Args == nil { + m.dialog.CloseFrontDialog() + argsDialog := dialog.NewArguments( + m.com, + "Custom Command Arguments", + "", + msg.Arguments, + msg, // Pass the action as the result + ) + m.dialog.OpenDialog(argsDialog) + break + } + content := msg.Content + if msg.Args != nil { + content = substituteArgs(content, msg.Args) + } + cmds = append(cmds, m.sendMessage(content)) + m.dialog.CloseFrontDialog() + case dialog.ActionRunMCPPrompt: + if len(msg.Arguments) > 0 && msg.Args == nil { + m.dialog.CloseFrontDialog() + title := msg.Title + if title == "" { + title = "MCP Prompt Arguments" + } + argsDialog := dialog.NewArguments( + m.com, + title, + msg.Description, + msg.Arguments, + msg, // Pass the action as the result + ) + m.dialog.OpenDialog(argsDialog) + break + } + cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args)) default: cmds = append(cmds, uiutil.CmdHandler(msg)) } @@ -927,6 +994,15 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { return tea.Batch(cmds...) } +// substituteArgs replaces $ARG_NAME placeholders in content with actual values. +func substituteArgs(content string, args map[string]string) string { + for name, value := range args { + placeholder := "$" + name + content = strings.ReplaceAll(content, placeholder, value) + } + return content +} + // openAPIKeyInputDialog opens the API key input dialog. func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { if m.dialog.ContainsDialog(dialog.APIKeyInputID) { @@ -1055,7 +1131,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.randomizePlaceholders() - return m.sendMessage(value, attachments) + return m.sendMessage(value, attachments...) case key.Matches(msg, m.keyMap.Chat.NewSession): if m.session == nil || m.session.ID == "" { break @@ -2013,7 +2089,7 @@ func (m *UI) renderSidebarLogo(width int) { } // sendMessage sends a message with the given content and attachments. -func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd { +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")) } @@ -2165,7 +2241,7 @@ func (m *UI) openCommandsDialog() tea.Cmd { sessionID = m.session.ID } - commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpCustomCommands) + commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts) if err != nil { return uiutil.ReportError(err) } @@ -2393,6 +2469,33 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) { ).Draw(scr, area) } +func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd { + load := func() tea.Msg { + prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments) + if err != nil { + // TODO: make this better + return uiutil.ReportError(err)() + } + + if prompt == "" { + return nil + } + return sendMessageMsg{ + Content: prompt, + } + } + + var cmds []tea.Cmd + if cmd := m.dialog.StartLoading(); cmd != nil { + cmds = append(cmds, cmd) + } + cmds = append(cmds, load, func() tea.Msg { + return closeDialogMsg{} + }) + + return tea.Sequence(cmds...) +} + // 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{ diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 442e3f78a449baae2c99868ae9434d69debce40e..878ed83eaf7c0eaaa490dc11546a72f0a9a8a539 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -333,6 +333,8 @@ type Styles struct { List lipgloss.Style + Spinner lipgloss.Style + // ContentPanel is used for content blocks with subtle background. ContentPanel lipgloss.Style @@ -340,6 +342,16 @@ type Styles struct { ScrollbarThumb lipgloss.Style ScrollbarTrack lipgloss.Style + // Arguments + Arguments struct { + Content lipgloss.Style + Description lipgloss.Style + InputLabelBlurred lipgloss.Style + InputLabelFocused lipgloss.Style + InputRequiredMarkBlurred lipgloss.Style + InputRequiredMarkFocused lipgloss.Style + } + Commands struct{} } @@ -1205,9 +1217,17 @@ func DefaultStyles() Styles { s.Dialog.List = base.Margin(0, 0, 1, 0) s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2) + s.Dialog.Spinner = base.Foreground(secondary) s.Dialog.ScrollbarThumb = base.Foreground(secondary) s.Dialog.ScrollbarTrack = base.Foreground(border) + s.Dialog.Arguments.Content = base.Padding(1) + s.Dialog.Arguments.Description = base.MarginBottom(1).MaxHeight(3) + s.Dialog.Arguments.InputLabelBlurred = base.Foreground(fgMuted) + s.Dialog.Arguments.InputLabelFocused = base.Bold(true) + s.Dialog.Arguments.InputRequiredMarkBlurred = base.Foreground(fgMuted).SetString("*") + s.Dialog.Arguments.InputRequiredMarkFocused = base.Foreground(primary).Bold(true).SetString("*") + s.Status.Help = lipgloss.NewStyle().Padding(0, 1) s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!") s.Status.InfoIndicator = s.Status.SuccessIndicator From f4b848b50db54d17cfb162d0b8ba80109ac67d5a Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 15 Jan 2026 15:42:50 +0100 Subject: [PATCH 141/335] chore: implement missing command and fix summarize (#1882) --- internal/ui/model/ui.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index c3c6edebbccab27b1072b9376e3c5169d3137894..b580c4d792eb60a571af5919ee582426279e0c87 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -375,6 +375,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.appendSessionMessage(msg.Payload)) case pubsub.UpdatedEvent: cmds = append(cmds, m.updateSessionMessage(msg.Payload)) + case pubsub.DeletedEvent: + m.chat.RemoveMessage(msg.Payload.ID) } case pubsub.Event[history.File]: cmds = append(cmds, m.handleFileEvent(msg.Payload)) @@ -887,13 +889,24 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session...")) break } - err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) - if err != nil { - cmds = append(cmds, uiutil.ReportError(err)) - } + cmds = append(cmds, func() tea.Msg { + err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) + if err != nil { + return uiutil.ReportError(err)() + } + return nil + }) + m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionToggleHelp: m.status.ToggleHelp() m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionExternalEditor: + if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) { + cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait...")) + break + } + cmds = append(cmds, m.openEditor(m.textarea.Value())) + m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionToggleCompactMode: cmds = append(cmds, m.toggleCompactMode()) m.dialog.CloseDialog(dialog.CommandsID) From 1f0605ae33299a887971130f120dc7fca5cf635f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 15 Jan 2026 09:46:03 -0500 Subject: [PATCH 142/335] refactor(ui): dialog: cleanup render logic and use RenderContext (#1871) --- internal/ui/dialog/commands.go | 23 +++++----- internal/ui/dialog/common.go | 72 ++++++++++++++++---------------- internal/ui/dialog/filepicker.go | 1 + internal/ui/dialog/models.go | 23 ++++------ internal/ui/dialog/sessions.go | 17 ++++---- 5 files changed, 66 insertions(+), 70 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index a6861a5c87707d7c0717ec4d3c50c1d995a528af..97addbba1036781840951f2503b8b7813a16d9eb 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -9,7 +9,6 @@ import ( "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" @@ -17,7 +16,6 @@ import ( "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/ansi" ) // CommandsID is the identifier for the commands dialog. @@ -247,6 +245,7 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { // we need to reset the command items when width changes c.setCommandItems(c.selected) } + innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize() heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + @@ -257,18 +256,20 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { c.list.SetSize(innerWidth, height-heightOffset) c.help.SetWidth(innerWidth) - radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0) - titleStyle := t.Dialog.Title - dialogStyle := t.Dialog.View.Width(width) - headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() - helpView := ansi.Truncate(c.help.View(c), innerWidth, "") - header := common.DialogTitle(t, "Commands", width-headerOffset) + radio + rc := NewRenderContext(t, width) + rc.Title = "Commands" + rc.TitleInfo = commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0) + inputView := t.Dialog.InputPrompt.Render(c.input.View()) + rc.AddPart(inputView) + listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render()) + rc.AddPart(listView) + rc.Help = c.help.View(c) if c.loading { - helpView = t.Dialog.HelpView.Width(width).Render(c.spinner.View() + " Generating Prompt...") + rc.Help = c.spinner.View() + " Generating Prompt..." } - view := HeaderInputListHelpView(t, width, c.list.Height(), header, - c.input.View(), c.list.Render(), helpView) + + view := rc.Render() cur := c.Cursor() DrawCenterCursor(scr, area, view, cur) diff --git a/internal/ui/dialog/common.go b/internal/ui/dialog/common.go index 7c812e4223fab44b38a9b4a41099055d737ec4c2..76b75064670935715f03e0d732b9df5070b9e9da 100644 --- a/internal/ui/dialog/common.go +++ b/internal/ui/dialog/common.go @@ -4,8 +4,10 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" ) // InputCursor adjusts the cursor position for an input field within a dialog. @@ -43,9 +45,15 @@ type RenderContext struct { // Width is the total width of the dialog including any margins, borders, // and paddings. Width int + // Gap is the gap between content parts. Zero means no gap. + Gap int // Title is the title of the dialog. This will be styled using the default // dialog title style and prepended to the content parts slice. Title string + // TitleInfo is additional information to display next to the title. This + // part is displayed as is, any styling must be applied before setting this + // field. + TitleInfo string // Parts are the rendered parts of the dialog. Parts []string // Help is the help view content. This will be appended to the content parts @@ -76,55 +84,47 @@ func (rc *RenderContext) Render() string { parts := []string{} if len(rc.Title) > 0 { + var titleInfoWidth int + if len(rc.TitleInfo) > 0 { + titleInfoWidth = lipgloss.Width(rc.TitleInfo) + } title := common.DialogTitle(rc.Styles, rc.Title, max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()- - titleStyle.GetHorizontalFrameSize())) - parts = append(parts, titleStyle.Render(title), "") + titleStyle.GetHorizontalFrameSize()- + titleInfoWidth)) + if len(rc.TitleInfo) > 0 { + title += rc.TitleInfo + } + parts = append(parts, titleStyle.Render(title)) + if rc.Gap > 0 { + parts = append(parts, make([]string, rc.Gap)...) + } } - for i, p := range rc.Parts { - if len(p) > 0 { - parts = append(parts, p) - } - if i < len(rc.Parts)-1 { - parts = append(parts, "") + if rc.Gap <= 0 { + parts = append(parts, rc.Parts...) + } else { + for i, p := range rc.Parts { + if len(p) > 0 { + parts = append(parts, p) + } + if i < len(rc.Parts)-1 { + parts = append(parts, make([]string, rc.Gap)...) + } } } if len(rc.Help) > 0 { - parts = append(parts, "") + if rc.Gap > 0 { + parts = append(parts, make([]string, rc.Gap)...) + } helpStyle := rc.Styles.Dialog.HelpView helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize()) - parts = append(parts, helpStyle.Render(rc.Help)) + helpView := ansi.Truncate(helpStyle.Render(rc.Help), rc.Width, "") + parts = append(parts, helpView) } content := strings.Join(parts, "\n") return dialogStyle.Render(content) } - -// HeaderInputListHelpView generates a view for dialogs with a header, input, -// list, and help sections. -func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, input, list, help string) string { - rc := NewRenderContext(t, width) - - titleStyle := t.Dialog.Title - inputStyle := t.Dialog.InputPrompt - listStyle := t.Dialog.List.Height(listHeight) - listContent := listStyle.Render(list) - - if len(header) > 0 { - rc.AddPart(titleStyle.Render(header)) - } - if len(input) > 0 { - rc.AddPart(inputStyle.Render(input)) - } - if len(list) > 0 { - rc.AddPart(listContent) - } - if len(help) > 0 { - rc.Help = help - } - - return rc.Render() -} diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index 8bfdbabd12f54911a7da85842152a17f0ade275a..a099cd2ff9fe6f707ac1e18c8470dba55794889c 100644 --- a/internal/ui/dialog/filepicker.go +++ b/internal/ui/dialog/filepicker.go @@ -234,6 +234,7 @@ func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := f.com.Styles rc := NewRenderContext(t, width) + rc.Gap = 1 rc.Title = "Add Image" rc.Help = f.help.View(f) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index c12c78e1f4753c01f80653bb6ee5e5013fc9ea09..543e610d013e58a71447814aedd22841aaa6bf2a 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -10,13 +10,11 @@ import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/uiutil" uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/ansi" ) // ModelType represents the type of model to select. @@ -253,19 +251,16 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { m.list.SetSize(innerWidth, height-heightOffset) m.help.SetWidth(innerWidth) - titleStyle := t.Dialog.Title - dialogStyle := t.Dialog.View + rc := NewRenderContext(t, width) + rc.Title = "Switch Model" + rc.TitleInfo = m.modelTypeRadioView() + inputView := t.Dialog.InputPrompt.Render(m.input.View()) + rc.AddPart(inputView) + listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render()) + rc.AddPart(listView) + rc.Help = m.help.View(m) - radios := m.modelTypeRadioView() - - headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() + - dialogStyle.GetHorizontalFrameSize() - - header := common.DialogTitle(t, "Switch Model", width-headerOffset) + radios - - helpView := ansi.Truncate(m.help.View(m), innerWidth, "") - view := HeaderInputListHelpView(t, width, m.list.Height(), header, - m.input.View(), m.list.Render(), helpView) + view := rc.Render() cur := m.Cursor() DrawCenterCursor(scr, area, view, cur) diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 7a4725fcb9fac33d349dd5d6d7812e8f70c00eaa..a70d13ce58fed2ddf1b292d30e405362cf093569 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -10,7 +10,6 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/ansi" ) // SessionsID is the identifier for the session selector dialog. @@ -154,15 +153,15 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { s.list.SetSize(innerWidth, height-heightOffset) s.help.SetWidth(innerWidth) - titleStyle := s.com.Styles.Dialog.Title - dialogStyle := s.com.Styles.Dialog.View.Width(width) - header := common.DialogTitle(s.com.Styles, "Switch Session", - max(0, width-dialogStyle.GetHorizontalFrameSize()- - titleStyle.GetHorizontalFrameSize())) + rc := NewRenderContext(t, width) + rc.Title = "Switch Session" + inputView := t.Dialog.InputPrompt.Render(s.input.View()) + rc.AddPart(inputView) + listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render()) + rc.AddPart(listView) + rc.Help = s.help.View(s) - helpView := ansi.Truncate(s.help.View(s), innerWidth, "") - view := HeaderInputListHelpView(s.com.Styles, width, s.list.Height(), header, - s.input.View(), s.list.Render(), helpView) + view := rc.Render() cur := s.Cursor() DrawCenterCursor(scr, area, view, cur) From d4ffb554cd6aaded7fd280d5b3f6aa793cba58a1 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 15 Jan 2026 11:39:56 -0500 Subject: [PATCH 143/335] feat(ui): filepicker: support kitty graphics in tmux This commit makes the file picker capable of previewing images when running inside a tmux session. It detects the tmux environment and wraps the Kitty graphics escape sequences appropriately for tmux. It works by using the Kitty Graphics chunking feature and wrapping each chunk in tmux passthrough sequences. This ensures that images are rendered correctly in tmux panes. --- internal/ui/dialog/filepicker.go | 4 +- internal/ui/image/image.go | 63 +++++++++++++++++++++++++++----- internal/ui/model/ui.go | 17 ++++++--- 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index a099cd2ff9fe6f707ac1e18c8470dba55794889c..fd2d11d7c4babb8bce99e5b77d5070f17a78ae8f 100644 --- a/internal/ui/dialog/filepicker.go +++ b/internal/ui/dialog/filepicker.go @@ -34,6 +34,7 @@ type FilePicker struct { fp filepicker.Model help help.Model previewingImage bool // indicates if an image is being previewed + isTmux bool km struct { Select, @@ -108,6 +109,7 @@ func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) { f.imgEnc = fimage.EncodingKitty } f.cellSize = caps.CellSize() + _, f.isTmux = caps.Env.LookupEnv("TMUX") } } @@ -189,7 +191,7 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action { img, err := loadImage(selFile) if err == nil { cmds = append(cmds, tea.Sequence( - f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight), + f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight, f.isTmux), func() tea.Msg { f.previewingImage = true return nil diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index e7f51239f8fd5ebecec1f5855911fbe459b340ac..e04a781c767c5677e0165cb3e191c60532dac0e6 100644 --- a/internal/ui/image/image.go +++ b/internal/ui/image/image.go @@ -2,6 +2,7 @@ package image import ( "bytes" + "errors" "fmt" "hash/fnv" "image" @@ -13,6 +14,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi/kitty" "github.com/charmbracelet/x/mosaic" @@ -33,6 +35,8 @@ type Capabilities struct { // SupportsKittyGraphics indicates whether the terminal supports the Kitty // graphics protocol. SupportsKittyGraphics bool + // Env is the terminal environment variables. + Env uv.Environ } // CellSize returns the size of a single terminal cell in pixels. @@ -55,12 +59,15 @@ func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellS // RequestCapabilities is a [tea.Cmd] that requests the terminal to report // its image related capabilities to the program. -func RequestCapabilities() tea.Cmd { - return tea.Raw( - ansi.WindowOp(14) + // Window size in pixels - // ID 31 is just a random ID used to detect Kitty graphics support. - ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"), - ) +func RequestCapabilities(env uv.Environ) tea.Cmd { + winOpReq := ansi.WindowOp(14) // Window size in pixels + // ID 31 is just a random ID used to detect Kitty graphics support. + kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24") + if _, isTmux := env.LookupEnv("TMUX"); isTmux { + kittyReq = ansi.TmuxPassthrough(kittyReq) + } + + return tea.Raw(winOpReq + kittyReq) } // TransmittedMsg is a message indicating that an image has been transmitted to @@ -161,7 +168,7 @@ func HasTransmitted(id string, cols, rows int) bool { // Transmit transmits the image data to the terminal if needed. This is used to // cache the image on the terminal for later rendering. -func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int) tea.Cmd { +func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int, tmux bool) tea.Cmd { if img == nil { return nil } @@ -188,13 +195,50 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i } var buf bytes.Buffer + rp, wp := io.Pipe() + go func() { + for { + // Read single Kitty graphic chunks from the pipe and wrap them + // for tmux if needed. + var out bytes.Buffer + seenEsc := false + for { + var p [1]byte + n, err := rp.Read(p[:]) + if n > 0 { + out.WriteByte(p[0]) + if p[0] == ansi.ESC { + seenEsc = true + } else if seenEsc && p[0] == '\\' { + // End of Kitty graphics sequence + break + } else { + seenEsc = false + } + } + if err != nil { + if !errors.Is(err, io.EOF) { + slog.Error("error reading from pipe", "err", err) + } + return + } + } + + seq := out.String() + if tmux { + seq = ansi.TmuxPassthrough(seq) + } + + buf.WriteString(seq) + } + }() + img := fitImage(id, img, cs, cols, rows) bounds := img.Bounds() imgWidth := bounds.Dx() imgHeight := bounds.Dy() - imgID := int(key.Hash()) - if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{ + if err := kitty.EncodeGraphics(wp, img, &kitty.Options{ ID: imgID, Action: kitty.TransmitAndPut, Transmission: kitty.Direct, @@ -205,6 +249,7 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i Rows: rows, VirtualPlacement: true, Quite: 1, + Chunk: true, }); err != nil { slog.Error("failed to encode image for kitty graphics", "err", err) return uiutil.ReportError(fmt.Errorf("failed to encode image")) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index f11ad75454dd18f3a710f925da52309935290c37..49aa7ecaf3b0df49cc2291fb0229a5bd8d6095ff 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1,6 +1,7 @@ package model import ( + "bytes" "context" "errors" "fmt" @@ -270,11 +271,6 @@ func (m *UI) Init() tea.Cmd { var cmds []tea.Cmd if m.QueryVersion { cmds = append(cmds, tea.RequestTerminalVersion) - // XXX: Right now, we're using the same logic to determine image - // support. Terminals like Apple Terminal and possibly others might - // bleed characters when querying for Kitty graphics via APC escape - // sequences. - cmds = append(cmds, timage.RequestCapabilities()) } // load the user commands async cmds = append(cmds, m.loadCustomCommands()) @@ -316,6 +312,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.sendProgressBar { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } + m.imgCaps.Env = uv.Environ(msg) + // XXX: Right now, we're using the same logic to determine image + // support. Terminals like Apple Terminal and possibly others might + // bleed characters when querying for Kitty graphics via APC escape + // sequences. + cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env)) case loadSessionMsg: m.state = uiChat if m.forceCompactMode { @@ -556,6 +558,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // captures the response. Any response means the terminal understands // the protocol. m.imgCaps.SupportsKittyGraphics = true + if !bytes.HasPrefix(msg.Payload, []byte("OK")) { + slog.Warn("unexpected Kitty graphics response", + "response", string(msg.Payload), + "options", msg.Options) + } default: if m.dialog.HasDialogs() { if cmd := m.handleDialogMsg(msg); cmd != nil { From f8a9b943e7069b76518c10b7db50694c5dd6345c Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 11:26:07 -0300 Subject: [PATCH 144/335] fix: ensure that hyper and copilot models show up even if not configured --- internal/ui/dialog/models.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index c12c78e1f4753c01f80653bb6ee5e5013fc9ea09..2a40b8135f9041dd505672490d964257d57bc343 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -459,10 +459,16 @@ func getFilteredProviders(cfg *config.Config) ([]catwalk.Provider, error) { if err != nil { return nil, fmt.Errorf("failed to get providers: %w", err) } - filteredProviders := []catwalk.Provider{} + var filteredProviders []catwalk.Provider for _, p := range providers { - hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$") - if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure { + 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) } } From c45b1521640ccc513507ae0815609e7c031941d0 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 13 Jan 2026 17:41:34 -0300 Subject: [PATCH 145/335] feat: implement hyper oauth flow in the new ui codebase --- internal/ui/dialog/actions.go | 23 ++ internal/ui/dialog/oauth.go | 423 ++++++++++++++++++++++++++++++++++ internal/ui/model/ui.go | 30 ++- internal/ui/styles/styles.go | 10 +- 4 files changed, 482 insertions(+), 4 deletions(-) create mode 100644 internal/ui/dialog/oauth.go diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 81911f9919be6c94ac158052b4a4e9b2236342a0..0048c4ac06540e22175d4663e1ee9123e1eba211 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -5,6 +5,7 @@ import ( "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/oauth" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" ) @@ -73,6 +74,28 @@ type ( } ) +// Messages for OAuth2 device flow dialog. +type ( + // ActionInitiateOAuth is sent when the device auth is initiated + // successfully. + ActionInitiateOAuth struct { + DeviceCode string + UserCode string + ExpiresIn int + VerificationURL string + } + + // ActionCompleteOAuth is sent when the device flow completes successfully. + ActionCompleteOAuth struct { + Token *oauth.Token + } + + // ActionOAuthErrored is sent when the device flow encounters an error. + ActionOAuthErrored struct { + Error error + } +) + // ActionCmd represents an action that carries a [tea.Cmd] to be passed to the // Bubble Tea program loop. type ActionCmd struct { diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go new file mode 100644 index 0000000000000000000000000000000000000000..68f22a76e037278d5884f44c9cfbdeea2f3549f9 --- /dev/null +++ b/internal/ui/dialog/oauth.go @@ -0,0 +1,423 @@ +package dialog + +import ( + "context" + "fmt" + "strings" + "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/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/oauth" + "github.com/charmbracelet/crush/internal/oauth/hyper" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" + "github.com/pkg/browser" +) + +// OAuthState represents the current state of the device flow. +type OAuthState int + +const ( + OAuthStateInitializing OAuthState = iota + OAuthStateDisplay + OAuthStateSuccess + OAuthStateError +) + +// OAuthID is the identifier for the model selection dialog. +const OAuthID = "oauth" + +// OAuth handles the OAuth flow authentication. +type OAuth struct { + com *common.Common + + provider catwalk.Provider + model config.SelectedModel + modelType config.SelectedModelType + + State OAuthState + + spinner spinner.Model + help help.Model + keyMap struct { + Copy key.Binding + Submit key.Binding + Close key.Binding + } + + width int + deviceCode string + userCode string + verificationURL string + expiresIn int + token *oauth.Token + cancelFunc context.CancelFunc +} + +var _ Dialog = (*OAuth)(nil) + +// NewOAuth creates a new device flow component. +func NewOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, error) { + t := com.Styles + + m := OAuth{} + m.com = com + m.provider = provider + m.model = model + m.modelType = modelType + m.width = 60 + m.State = OAuthStateInitializing + + m.spinner = spinner.New( + spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(t.Base.Foreground(t.GreenLight)), + ) + + m.help = help.New() + m.help.Styles = t.DialogHelpStyles() + + m.keyMap.Copy = key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "copy code"), + ) + m.keyMap.Submit = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "copy & open"), + ) + m.keyMap.Close = CloseKey + + return &m, nil +} + +// ID implements Dialog. +func (m *OAuth) ID() string { + return OAuthID +} + +// Init implements Dialog. +func (m *OAuth) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, m.initiateDeviceAuth) +} + +// HandleMsg handles messages and state transitions. +func (m *OAuth) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case spinner.TickMsg: + switch m.State { + case OAuthStateInitializing, OAuthStateDisplay: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keyMap.Copy): + cmd := m.copyCode() + return ActionCmd{cmd} + + case key.Matches(msg, m.keyMap.Submit): + switch m.State { + case OAuthStateSuccess: + return m.saveKeyAndContinue() + + default: + cmd := m.copyCodeAndOpenURL() + return ActionCmd{cmd} + } + + case key.Matches(msg, m.keyMap.Close): + switch m.State { + case OAuthStateSuccess: + return m.saveKeyAndContinue() + + default: + return ActionClose{} + } + } + + case ActionInitiateOAuth: + m.deviceCode = msg.DeviceCode + m.userCode = msg.UserCode + m.expiresIn = msg.ExpiresIn + m.verificationURL = msg.VerificationURL + m.State = OAuthStateDisplay + return ActionCmd{m.startPolling(msg.DeviceCode)} + + case ActionCompleteOAuth: + m.State = OAuthStateSuccess + m.token = msg.Token + return ActionCmd{m.stopPolling} + + case ActionOAuthErrored: + m.State = OAuthStateError + cmd := tea.Batch(m.stopPolling, uiutil.ReportError(msg.Error)) + return ActionCmd{cmd} + } + return nil +} + +// View renders the device flow dialog. +func (m *OAuth) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + var ( + t = m.com.Styles + dialogStyle = t.Dialog.View.Width(m.width) + view = dialogStyle.Render(m.dialogContent()) + ) + DrawCenterCursor(scr, area, view, nil) + return nil +} + +func (m *OAuth) dialogContent() string { + var ( + t = m.com.Styles + helpStyle = t.Dialog.HelpView + ) + + switch m.State { + case OAuthStateInitializing: + return m.innerDialogContent() + + default: + elements := []string{ + m.headerContent(), + m.innerDialogContent(), + helpStyle.Render(m.help.View(m)), + } + return strings.Join(elements, "\n") + } +} + +func (m *OAuth) headerContent() string { + var ( + t = m.com.Styles + titleStyle = t.Dialog.Title + dialogStyle = t.Dialog.View.Width(m.width) + headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() + ) + return common.DialogTitle(t, titleStyle.Render("Authenticate with Hyper"), m.width-headerOffset) +} + +func (m *OAuth) innerDialogContent() string { + var ( + t = m.com.Styles + 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 m.State { + case OAuthStateInitializing: + return lipgloss.NewStyle(). + Margin(1, 1). + Width(m.width - 2). + Align(lipgloss.Center). + Render( + greenStyle.Render(m.spinner.View()) + + mutedStyle.Render("Initializing..."), + ) + + case OAuthStateDisplay: + instructions := lipgloss.NewStyle(). + Margin(1). + Width(m.width - 2). + Render( + whiteStyle.Render("Press ") + + primaryStyle.Render("enter") + + whiteStyle.Render(" to copy the code below and open the browser."), + ) + + codeBox := lipgloss.NewStyle(). + Width(m.width-2). + Height(7). + Align(lipgloss.Center, lipgloss.Center). + Background(t.BgBaseLighter). + Margin(1). + Render( + lipgloss.NewStyle(). + Bold(true). + Foreground(t.White). + Render(m.userCode), + ) + + link := linkStyle.Hyperlink(m.verificationURL, "id=oauth-verify").Render(m.verificationURL) + url := mutedStyle. + Margin(0, 1). + Width(m.width - 2). + Render("Browser not opening? Refer to\n" + link) + + waiting := greenStyle. + Width(m.width - 2). + Margin(1). + Render(m.spinner.View() + "Verifying...") + + return lipgloss.JoinVertical( + lipgloss.Left, + instructions, + codeBox, + url, + waiting, + ) + + case OAuthStateSuccess: + return greenStyle. + Margin(1). + Width(m.width - 2). + Align(lipgloss.Center). + Render("Authentication successful!") + + case OAuthStateError: + return lipgloss.NewStyle(). + Margin(1). + Width(m.width - 2). + Render(errorStyle.Render("Authentication failed.")) + + default: + return "" + } +} + +// FullHelp returns the full help view. +func (m *OAuth) FullHelp() [][]key.Binding { + return [][]key.Binding{m.ShortHelp()} +} + +// ShortHelp returns the full help view. +func (m *OAuth) ShortHelp() []key.Binding { + switch m.State { + case OAuthStateError: + return []key.Binding{m.keyMap.Close} + + case OAuthStateSuccess: + return []key.Binding{ + key.NewBinding( + key.WithKeys("finish", "ctrl+y", "esc"), + key.WithHelp("enter", "finish"), + ), + } + + default: + return []key.Binding{ + m.keyMap.Copy, + m.keyMap.Submit, + m.keyMap.Close, + } + } +} + +func (d *OAuth) copyCode() tea.Cmd { + if d.State != OAuthStateDisplay { + return nil + } + return tea.Sequence( + tea.SetClipboard(d.userCode), + uiutil.ReportInfo("Code copied to clipboard"), + ) +} + +func (d *OAuth) copyCodeAndOpenURL() tea.Cmd { + if d.State != OAuthStateDisplay { + return nil + } + return tea.Sequence( + tea.SetClipboard(d.userCode), + func() tea.Msg { + if err := browser.OpenURL(d.verificationURL); err != nil { + return ActionOAuthErrored{fmt.Errorf("failed to open browser: %w", err)} + } + return nil + }, + uiutil.ReportInfo("Code copied and URL opened"), + ) +} + +func (m *OAuth) saveKeyAndContinue() Action { + cfg := m.com.Config() + + 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 ActionSelectModel{ + Provider: m.provider, + Model: m.model, + ModelType: m.modelType, + } +} + +func (m *OAuth) initiateDeviceAuth() tea.Msg { + minimumWait := 750 * time.Millisecond + startTime := time.Now() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + authResp, err := hyper.InitiateDeviceAuth(ctx) + + ellapsed := time.Since(startTime) + if ellapsed < minimumWait { + time.Sleep(minimumWait - ellapsed) + } + + if err != nil { + return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)} + } + + return ActionInitiateOAuth{ + DeviceCode: authResp.DeviceCode, + UserCode: authResp.UserCode, + ExpiresIn: authResp.ExpiresIn, + VerificationURL: authResp.VerificationURL, + } +} + +// startPolling starts polling for the device token. +func (m *OAuth) startPolling(deviceCode string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelFunc = cancel + + refreshToken, err := hyper.PollForToken(ctx, deviceCode, m.expiresIn) + if err != nil { + if ctx.Err() != nil { + return nil + } + return ActionOAuthErrored{err} + } + + token, err := hyper.ExchangeToken(ctx, refreshToken) + if err != nil { + return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)} + } + + introspect, err := hyper.IntrospectToken(ctx, token.AccessToken) + if err != nil { + return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)} + } + if !introspect.Active { + return ActionOAuthErrored{fmt.Errorf("access token is not active")} + } + + return ActionCompleteOAuth{token} + } +} + +func (m *OAuth) stopPolling() tea.Msg { + if m.cancelFunc != nil { + m.cancelFunc() + } + return nil +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index b580c4d792eb60a571af5919ee582426279e0c87..a61c3050ac7f982c0141b8525b304c92e379b5bc 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -934,7 +934,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { _, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider) if !isProviderConfigured { m.dialog.CloseDialog(dialog.ModelsID) - if cmd := m.openAPIKeyInputDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil { + if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil { cmds = append(cmds, cmd) } break @@ -950,6 +950,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) cmds = append(cmds, uiutil.ReportInfo(modelMsg)) m.dialog.CloseDialog(dialog.APIKeyInputID) + m.dialog.CloseDialog(dialog.OAuthID) m.dialog.CloseDialog(dialog.ModelsID) // TODO CHANGE case dialog.ActionPermissionResponse: @@ -1016,7 +1017,17 @@ func substituteArgs(content string, args map[string]string) string { return content } -// openAPIKeyInputDialog opens the API key input dialog. +func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { + switch provider.ID { + case "hyper": + return m.openOAuthDialog(provider, model, modelType) + case catwalk.InferenceProviderCopilot: + return m.openOAuthDialog(provider, model, modelType) + default: + return m.openAPIKeyInputDialog(provider, model, modelType) + } +} + func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { if m.dialog.ContainsDialog(dialog.APIKeyInputID) { m.dialog.BringToFront(dialog.APIKeyInputID) @@ -1031,6 +1042,21 @@ func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.Selec return nil } +func (m *UI) openOAuthDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { + if m.dialog.ContainsDialog(dialog.OAuthID) { + m.dialog.BringToFront(dialog.OAuthID) + return nil + } + + oAuthDialog, err := dialog.NewOAuth(m.com, provider, model, modelType) + if err != nil { + return uiutil.ReportError(err) + } + m.dialog.OpenDialog(oAuthDialog) + + return oAuthDialog.Init() +} + func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { var cmds []tea.Cmd diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 878ed83eaf7c0eaaa490dc11546a72f0a9a8a539..bb39cc0a583cbaa834c4c59e139d97cc72a2de76 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -173,12 +173,14 @@ type Styles struct { FgSubtle color.Color Border color.Color BorderColor color.Color // Border focus color + Error color.Color Warning color.Color Info color.Color White color.Color BlueLight color.Color Blue color.Color BlueDark color.Color + GreenLight color.Color Green color.Color GreenDark color.Color Red color.Color @@ -459,6 +461,7 @@ func DefaultStyles() Styles { borderFocus = charmtone.Charple // Status + error = charmtone.Sriracha warning = charmtone.Zest info = charmtone.Malibu @@ -473,8 +476,9 @@ func DefaultStyles() Styles { yellow = charmtone.Mustard // citron = charmtone.Citron - green = charmtone.Julep - greenDark = charmtone.Guac + greenLight = charmtone.Bok + green = charmtone.Julep + greenDark = charmtone.Guac // greenLight = charmtone.Bok red = charmtone.Coral @@ -505,12 +509,14 @@ func DefaultStyles() Styles { s.FgSubtle = fgSubtle s.Border = border s.BorderColor = borderFocus + s.Error = error s.Warning = warning s.Info = info s.White = white s.BlueLight = blueLight s.Blue = blue s.BlueDark = blueDark + s.GreenLight = greenLight s.Green = green s.GreenDark = greenDark s.Red = red From ccb1a643e9b0959e19bd0263e493a8b7a8544972 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 14 Jan 2026 18:09:02 -0300 Subject: [PATCH 146/335] refactor: make oauth dialog generic and move provider logic to interface --- internal/ui/dialog/oauth.go | 95 +++++++------------------------ internal/ui/dialog/oauth_hyper.go | 90 +++++++++++++++++++++++++++++ internal/ui/model/ui.go | 12 ++-- 3 files changed, 117 insertions(+), 80 deletions(-) create mode 100644 internal/ui/dialog/oauth_hyper.go diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index 68f22a76e037278d5884f44c9cfbdeea2f3549f9..f87a583be4160edf1d6a1e42de29f7dd01a513bc 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "strings" - "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" @@ -14,13 +13,19 @@ import ( "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/oauth" - "github.com/charmbracelet/crush/internal/oauth/hyper" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/uiutil" uv "github.com/charmbracelet/ultraviolet" "github.com/pkg/browser" ) +type OAuthProvider interface { + name() string + initiateAuth() tea.Msg + startPolling(deviceCode string, expiresIn int) tea.Cmd + stopPolling() tea.Msg +} + // OAuthState represents the current state of the device flow. type OAuthState int @@ -38,9 +43,10 @@ const OAuthID = "oauth" type OAuth struct { com *common.Common - provider catwalk.Provider - model config.SelectedModel - modelType config.SelectedModelType + provider catwalk.Provider + model config.SelectedModel + modelType config.SelectedModelType + oAuthProvider OAuthProvider State OAuthState @@ -63,8 +69,8 @@ type OAuth struct { var _ Dialog = (*OAuth)(nil) -// NewOAuth creates a new device flow component. -func NewOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, error) { +// newOAuth creates a new device flow component. +func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, error) { t := com.Styles m := OAuth{} @@ -72,6 +78,7 @@ func NewOAuth(com *common.Common, provider catwalk.Provider, model config.Select m.provider = provider m.model = model m.modelType = modelType + m.oAuthProvider = oAuthProvider m.width = 60 m.State = OAuthStateInitializing @@ -103,7 +110,7 @@ func (m *OAuth) ID() string { // Init implements Dialog. func (m *OAuth) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, m.initiateDeviceAuth) + return tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth) } // HandleMsg handles messages and state transitions. @@ -151,16 +158,16 @@ func (m *OAuth) HandleMsg(msg tea.Msg) Action { m.expiresIn = msg.ExpiresIn m.verificationURL = msg.VerificationURL m.State = OAuthStateDisplay - return ActionCmd{m.startPolling(msg.DeviceCode)} + return ActionCmd{m.oAuthProvider.startPolling(msg.DeviceCode, msg.ExpiresIn)} case ActionCompleteOAuth: m.State = OAuthStateSuccess m.token = msg.Token - return ActionCmd{m.stopPolling} + return ActionCmd{m.oAuthProvider.stopPolling} case ActionOAuthErrored: m.State = OAuthStateError - cmd := tea.Batch(m.stopPolling, uiutil.ReportError(msg.Error)) + cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error)) return ActionCmd{cmd} } return nil @@ -204,7 +211,7 @@ func (m *OAuth) headerContent() string { dialogStyle = t.Dialog.View.Width(m.width) headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() ) - return common.DialogTitle(t, titleStyle.Render("Authenticate with Hyper"), m.width-headerOffset) + return common.DialogTitle(t, titleStyle.Render("Authenticate with "+m.oAuthProvider.name()), m.width-headerOffset) } func (m *OAuth) innerDialogContent() string { @@ -357,67 +364,3 @@ func (m *OAuth) saveKeyAndContinue() Action { ModelType: m.modelType, } } - -func (m *OAuth) initiateDeviceAuth() tea.Msg { - minimumWait := 750 * time.Millisecond - startTime := time.Now() - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - authResp, err := hyper.InitiateDeviceAuth(ctx) - - ellapsed := time.Since(startTime) - if ellapsed < minimumWait { - time.Sleep(minimumWait - ellapsed) - } - - if err != nil { - return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)} - } - - return ActionInitiateOAuth{ - DeviceCode: authResp.DeviceCode, - UserCode: authResp.UserCode, - ExpiresIn: authResp.ExpiresIn, - VerificationURL: authResp.VerificationURL, - } -} - -// startPolling starts polling for the device token. -func (m *OAuth) startPolling(deviceCode string) tea.Cmd { - return func() tea.Msg { - ctx, cancel := context.WithCancel(context.Background()) - m.cancelFunc = cancel - - refreshToken, err := hyper.PollForToken(ctx, deviceCode, m.expiresIn) - if err != nil { - if ctx.Err() != nil { - return nil - } - return ActionOAuthErrored{err} - } - - token, err := hyper.ExchangeToken(ctx, refreshToken) - if err != nil { - return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)} - } - - introspect, err := hyper.IntrospectToken(ctx, token.AccessToken) - if err != nil { - return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)} - } - if !introspect.Active { - return ActionOAuthErrored{fmt.Errorf("access token is not active")} - } - - return ActionCompleteOAuth{token} - } -} - -func (m *OAuth) stopPolling() tea.Msg { - if m.cancelFunc != nil { - m.cancelFunc() - } - return nil -} diff --git a/internal/ui/dialog/oauth_hyper.go b/internal/ui/dialog/oauth_hyper.go new file mode 100644 index 0000000000000000000000000000000000000000..65d3e34e817384e8e123e4720f7cdd310213aa87 --- /dev/null +++ b/internal/ui/dialog/oauth_hyper.go @@ -0,0 +1,90 @@ +package dialog + +import ( + "context" + "fmt" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/oauth/hyper" + "github.com/charmbracelet/crush/internal/ui/common" +) + +func NewOAuthHyper(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, error) { + return newOAuth(com, provider, model, modelType, &OAuthHyper{}) +} + +type OAuthHyper struct { + cancelFunc func() +} + +var _ OAuthProvider = (*OAuthHyper)(nil) + +func (m *OAuthHyper) name() string { + return "Hyper" +} + +func (m *OAuthHyper) initiateAuth() tea.Msg { + minimumWait := 750 * time.Millisecond + startTime := time.Now() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + authResp, err := hyper.InitiateDeviceAuth(ctx) + + ellapsed := time.Since(startTime) + if ellapsed < minimumWait { + time.Sleep(minimumWait - ellapsed) + } + + if err != nil { + return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)} + } + + return ActionInitiateOAuth{ + DeviceCode: authResp.DeviceCode, + UserCode: authResp.UserCode, + ExpiresIn: authResp.ExpiresIn, + VerificationURL: authResp.VerificationURL, + } +} + +func (m *OAuthHyper) startPolling(deviceCode string, expiresIn int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelFunc = cancel + + refreshToken, err := hyper.PollForToken(ctx, deviceCode, expiresIn) + if err != nil { + if ctx.Err() != nil { + return nil + } + return ActionOAuthErrored{err} + } + + token, err := hyper.ExchangeToken(ctx, refreshToken) + if err != nil { + return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)} + } + + introspect, err := hyper.IntrospectToken(ctx, token.AccessToken) + if err != nil { + return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)} + } + if !introspect.Active { + return ActionOAuthErrored{fmt.Errorf("access token is not active")} + } + + return ActionCompleteOAuth{token} + } +} + +func (m *OAuthHyper) stopPolling() tea.Msg { + if m.cancelFunc != nil { + m.cancelFunc() + } + return nil +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a61c3050ac7f982c0141b8525b304c92e379b5bc..e9bd2fd3cedb5dd93bb5e1bfe31e2fb2b6f65f72 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1020,9 +1020,9 @@ func substituteArgs(content string, args map[string]string) string { func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { switch provider.ID { case "hyper": - return m.openOAuthDialog(provider, model, modelType) + return m.openOAuthHyperDialog(provider, model, modelType) case catwalk.InferenceProviderCopilot: - return m.openOAuthDialog(provider, model, modelType) + return m.openOAuthCopilotDialog(provider, model, modelType) default: return m.openAPIKeyInputDialog(provider, model, modelType) } @@ -1042,13 +1042,13 @@ func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.Selec return nil } -func (m *UI) openOAuthDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { +func (m *UI) openOAuthHyperDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { if m.dialog.ContainsDialog(dialog.OAuthID) { m.dialog.BringToFront(dialog.OAuthID) return nil } - oAuthDialog, err := dialog.NewOAuth(m.com, provider, model, modelType) + oAuthDialog, err := dialog.NewOAuthHyper(m.com, provider, model, modelType) if err != nil { return uiutil.ReportError(err) } @@ -1057,6 +1057,10 @@ func (m *UI) openOAuthDialog(provider catwalk.Provider, model config.SelectedMod return oAuthDialog.Init() } +func (m *UI) openOAuthCopilotDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { + panic("TODO") +} + func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { var cmds []tea.Cmd From f2f44540f58e3cca3602f11d5bd7c002e3ff3109 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 14:23:45 -0300 Subject: [PATCH 147/335] feat: implement github copilot oauth flow in the new ui codebase --- internal/ui/dialog/actions.go | 1 + internal/ui/dialog/oauth.go | 2 + internal/ui/dialog/oauth_copilot.go | 72 +++++++++++++++++++++++++++++ internal/ui/model/ui.go | 27 +++++++++-- 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 internal/ui/dialog/oauth_copilot.go diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 0048c4ac06540e22175d4663e1ee9123e1eba211..1c7e9c2cdd9338cac4f28aee0d87ec7c08f5fa15 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -83,6 +83,7 @@ type ( UserCode string ExpiresIn int VerificationURL string + Interval int } // ActionCompleteOAuth is sent when the device flow completes successfully. diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index f87a583be4160edf1d6a1e42de29f7dd01a513bc..1268d8a2324fff56843e8468cb2d4d3cd66895af 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -63,6 +63,7 @@ type OAuth struct { userCode string verificationURL string expiresIn int + interval int token *oauth.Token cancelFunc context.CancelFunc } @@ -157,6 +158,7 @@ func (m *OAuth) HandleMsg(msg tea.Msg) Action { m.userCode = msg.UserCode m.expiresIn = msg.ExpiresIn m.verificationURL = msg.VerificationURL + m.interval = msg.Interval m.State = OAuthStateDisplay return ActionCmd{m.oAuthProvider.startPolling(msg.DeviceCode, msg.ExpiresIn)} diff --git a/internal/ui/dialog/oauth_copilot.go b/internal/ui/dialog/oauth_copilot.go new file mode 100644 index 0000000000000000000000000000000000000000..2364c02804ffc0eca776b24849bdb38aacf1df94 --- /dev/null +++ b/internal/ui/dialog/oauth_copilot.go @@ -0,0 +1,72 @@ +package dialog + +import ( + "context" + "fmt" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/oauth/copilot" + "github.com/charmbracelet/crush/internal/ui/common" +) + +func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, error) { + return newOAuth(com, provider, model, modelType, &OAuthCopilot{}) +} + +type OAuthCopilot struct { + deviceCode *copilot.DeviceCode + cancelFunc func() +} + +var _ OAuthProvider = (*OAuthCopilot)(nil) + +func (m *OAuthCopilot) name() string { + return "GitHub Copilot" +} + +func (m *OAuthCopilot) initiateAuth() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + deviceCode, err := copilot.RequestDeviceCode(ctx) + if err != nil { + return ActionOAuthErrored{Error: fmt.Errorf("failed to initiate device auth: %w", err)} + } + + m.deviceCode = deviceCode + + return ActionInitiateOAuth{ + DeviceCode: deviceCode.DeviceCode, + UserCode: deviceCode.UserCode, + VerificationURL: deviceCode.VerificationURI, + ExpiresIn: deviceCode.ExpiresIn, + Interval: deviceCode.Interval, + } +} + +func (m *OAuthCopilot) startPolling(deviceCode string, expiresIn int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelFunc = cancel + + token, err := copilot.PollForToken(ctx, m.deviceCode) + if err != nil { + if ctx.Err() != nil { + return nil // cancelled, don't report error. + } + return ActionOAuthErrored{Error: err} + } + + return ActionCompleteOAuth{Token: token} + } +} + +func (m *OAuthCopilot) stopPolling() tea.Msg { + if m.cancelFunc != nil { + m.cancelFunc() + } + return nil +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index e9bd2fd3cedb5dd93bb5e1bfe31e2fb2b6f65f72..a79598bd857688491b57c58d85244e2499408498 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -931,8 +931,18 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { break } - _, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider) - if !isProviderConfigured { + var ( + providerID = msg.Model.Provider + isCopilot = providerID == string(catwalk.InferenceProviderCopilot) + isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok } + ) + + // Attempt to import GitHub Copilot tokens from VSCode if available. + if isCopilot && !isConfigured() { + config.Get().ImportCopilot() + } + + if !isConfigured() { m.dialog.CloseDialog(dialog.ModelsID) if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil { cmds = append(cmds, cmd) @@ -1058,7 +1068,18 @@ func (m *UI) openOAuthHyperDialog(provider catwalk.Provider, model config.Select } func (m *UI) openOAuthCopilotDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { - panic("TODO") + if m.dialog.ContainsDialog(dialog.OAuthID) { + m.dialog.BringToFront(dialog.OAuthID) + return nil + } + + oAuthDialog, err := dialog.NewOAuthCopilot(m.com, provider, model, modelType) + if err != nil { + return uiutil.ReportError(err) + } + m.dialog.OpenDialog(oAuthDialog) + + return oAuthDialog.Init() } func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { From 4c938aa1f773150fb5119c640e9427d684a1015e Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 14:43:31 -0300 Subject: [PATCH 148/335] fix: address "verifying..." now showing on side of spinner --- internal/ui/dialog/oauth.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index 1268d8a2324fff56843e8468cb2d4d3cd66895af..4a97dc21cf6af06f916fe208f237f0f911576513 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -267,10 +267,12 @@ func (m *OAuth) innerDialogContent() string { Width(m.width - 2). Render("Browser not opening? Refer to\n" + link) - waiting := greenStyle. + waiting := lipgloss.NewStyle(). + Margin(1, 1). Width(m.width - 2). - Margin(1). - Render(m.spinner.View() + "Verifying...") + Render( + greenStyle.Render(m.spinner.View()) + mutedStyle.Render("Verifying..."), + ) return lipgloss.JoinVertical( lipgloss.Left, From 6caf8788dcbaa9bf3ee2326abc738dbf428f8f5a Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 14:53:39 -0300 Subject: [PATCH 149/335] fix: align "authentication successful" on the left Co-authored-by: Christian Rocha --- internal/ui/dialog/oauth.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index 4a97dc21cf6af06f916fe208f237f0f911576513..5d4a16cc409ab8d0515381cf7cbca41ae332c5cc 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -286,7 +286,6 @@ func (m *OAuth) innerDialogContent() string { return greenStyle. Margin(1). Width(m.width - 2). - Align(lipgloss.Center). Render("Authentication successful!") case OAuthStateError: From f27f5460223ea98f91ef41b826218d1de882c3e8 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 14:58:23 -0300 Subject: [PATCH 150/335] fix: address double vertical margins between sections Co-authored-by: Christian Rocha --- internal/ui/dialog/oauth.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index 5d4a16cc409ab8d0515381cf7cbca41ae332c5cc..96ea4ee81cf35627926fe920b5ca2680ef9b67af 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -240,7 +240,7 @@ func (m *OAuth) innerDialogContent() string { case OAuthStateDisplay: instructions := lipgloss.NewStyle(). - Margin(1). + Margin(0, 1). Width(m.width - 2). Render( whiteStyle.Render("Press ") + @@ -253,7 +253,7 @@ func (m *OAuth) innerDialogContent() string { Height(7). Align(lipgloss.Center, lipgloss.Center). Background(t.BgBaseLighter). - Margin(1). + Margin(0, 1). Render( lipgloss.NewStyle(). Bold(true). @@ -268,7 +268,7 @@ func (m *OAuth) innerDialogContent() string { Render("Browser not opening? Refer to\n" + link) waiting := lipgloss.NewStyle(). - Margin(1, 1). + Margin(0, 1). Width(m.width - 2). Render( greenStyle.Render(m.spinner.View()) + mutedStyle.Render("Verifying..."), @@ -276,10 +276,15 @@ func (m *OAuth) innerDialogContent() string { return lipgloss.JoinVertical( lipgloss.Left, + "", instructions, + "", codeBox, + "", url, + "", waiting, + "", ) case OAuthStateSuccess: From 9f7be9d2924780b7b10d70061d46d2b9ff2bc997 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 15:06:39 -0300 Subject: [PATCH 151/335] refactor: remove init in favor of returning cmd on new Co-authored-by: Ayman Bagabas --- internal/ui/dialog/oauth.go | 9 ++------- internal/ui/dialog/oauth_copilot.go | 2 +- internal/ui/dialog/oauth_hyper.go | 2 +- internal/ui/model/ui.go | 16 ++++------------ 4 files changed, 8 insertions(+), 21 deletions(-) diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index 96ea4ee81cf35627926fe920b5ca2680ef9b67af..ae5a2ab25a1ec596ba50ea6b3a0d03f560f1b10d 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -71,7 +71,7 @@ type OAuth struct { var _ Dialog = (*OAuth)(nil) // newOAuth creates a new device flow component. -func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, error) { +func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, tea.Cmd) { t := com.Styles m := OAuth{} @@ -101,7 +101,7 @@ func newOAuth(com *common.Common, provider catwalk.Provider, model config.Select ) m.keyMap.Close = CloseKey - return &m, nil + return &m, tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth) } // ID implements Dialog. @@ -109,11 +109,6 @@ func (m *OAuth) ID() string { return OAuthID } -// Init implements Dialog. -func (m *OAuth) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth) -} - // HandleMsg handles messages and state transitions. func (m *OAuth) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { diff --git a/internal/ui/dialog/oauth_copilot.go b/internal/ui/dialog/oauth_copilot.go index 2364c02804ffc0eca776b24849bdb38aacf1df94..19e389b38a965c4c22ba1b2080b029975aaedc19 100644 --- a/internal/ui/dialog/oauth_copilot.go +++ b/internal/ui/dialog/oauth_copilot.go @@ -12,7 +12,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" ) -func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, error) { +func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) { return newOAuth(com, provider, model, modelType, &OAuthCopilot{}) } diff --git a/internal/ui/dialog/oauth_hyper.go b/internal/ui/dialog/oauth_hyper.go index 65d3e34e817384e8e123e4720f7cdd310213aa87..478960b0df10f62d88b65450de360f4db6d6cd0c 100644 --- a/internal/ui/dialog/oauth_hyper.go +++ b/internal/ui/dialog/oauth_hyper.go @@ -12,7 +12,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" ) -func NewOAuthHyper(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, error) { +func NewOAuthHyper(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) { return newOAuth(com, provider, model, modelType, &OAuthHyper{}) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a79598bd857688491b57c58d85244e2499408498..4d3ecafe07eeb3a88c4b88f90bc60b0fe4d8266e 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1058,13 +1058,9 @@ func (m *UI) openOAuthHyperDialog(provider catwalk.Provider, model config.Select return nil } - oAuthDialog, err := dialog.NewOAuthHyper(m.com, provider, model, modelType) - if err != nil { - return uiutil.ReportError(err) - } + oAuthDialog, cmd := dialog.NewOAuthHyper(m.com, provider, model, modelType) m.dialog.OpenDialog(oAuthDialog) - - return oAuthDialog.Init() + return cmd } func (m *UI) openOAuthCopilotDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { @@ -1073,13 +1069,9 @@ func (m *UI) openOAuthCopilotDialog(provider catwalk.Provider, model config.Sele return nil } - oAuthDialog, err := dialog.NewOAuthCopilot(m.com, provider, model, modelType) - if err != nil { - return uiutil.ReportError(err) - } + oAuthDialog, cmd := dialog.NewOAuthCopilot(m.com, provider, model, modelType) m.dialog.OpenDialog(oAuthDialog) - - return oAuthDialog.Init() + return cmd } func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { From 8b5cc439a99cdf71bc8c66b62d4ed4471260585b Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 16:53:53 -0300 Subject: [PATCH 152/335] refactor: remove duplication on functions to open dialogs Co-authored-by: Ayman Bagabas --- internal/ui/dialog/api_key_input.go | 2 +- internal/ui/model/ui.go | 45 +++++++---------------------- 2 files changed, 12 insertions(+), 35 deletions(-) diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index e28dea2b823143d176d796c8775e8024df61d0bb..430f7b4629294faa83bad9b5b90ca363ceb6f1b7 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -54,7 +54,7 @@ type APIKeyInput struct { var _ Dialog = (*APIKeyInput)(nil) // NewAPIKeyInput creates a new Models dialog. -func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, error) { +func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, tea.Cmd) { t := com.Styles m := APIKeyInput{} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 4d3ecafe07eeb3a88c4b88f90bc60b0fe4d8266e..de4b0e91fc38ec280ad9d9249ab17e88dedcf748 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1028,49 +1028,26 @@ func substituteArgs(content string, args map[string]string) string { } func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { + var ( + dlg dialog.Dialog + cmd tea.Cmd + ) + switch provider.ID { case "hyper": - return m.openOAuthHyperDialog(provider, model, modelType) + dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType) case catwalk.InferenceProviderCopilot: - return m.openOAuthCopilotDialog(provider, model, modelType) + dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType) default: - return m.openAPIKeyInputDialog(provider, model, modelType) - } -} - -func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { - if m.dialog.ContainsDialog(dialog.APIKeyInputID) { - m.dialog.BringToFront(dialog.APIKeyInputID) - return nil - } - - apiKeyInputDialog, err := dialog.NewAPIKeyInput(m.com, provider, model, modelType) - if err != nil { - return uiutil.ReportError(err) + dlg, cmd = dialog.NewAPIKeyInput(m.com, provider, model, modelType) } - m.dialog.OpenDialog(apiKeyInputDialog) - return nil -} - -func (m *UI) openOAuthHyperDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { - if m.dialog.ContainsDialog(dialog.OAuthID) { - m.dialog.BringToFront(dialog.OAuthID) - return nil - } - - oAuthDialog, cmd := dialog.NewOAuthHyper(m.com, provider, model, modelType) - m.dialog.OpenDialog(oAuthDialog) - return cmd -} -func (m *UI) openOAuthCopilotDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { - if m.dialog.ContainsDialog(dialog.OAuthID) { - m.dialog.BringToFront(dialog.OAuthID) + if m.dialog.ContainsDialog(dlg.ID()) { + m.dialog.BringToFront(dlg.ID()) return nil } - oAuthDialog, cmd := dialog.NewOAuthCopilot(m.com, provider, model, modelType) - m.dialog.OpenDialog(oAuthDialog) + m.dialog.OpenDialog(dlg) return cmd } From ded1e6b277bbfac8168dc1f978de3da5f3bc0138 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 15 Jan 2026 12:00:37 -0500 Subject: [PATCH 153/335] fix(ui): filepicker: simplify tmux kitty image encoding --- go.mod | 4 +-- go.sum | 8 +++--- internal/ui/image/image.go | 52 ++++++++------------------------------ 3 files changed, 17 insertions(+), 47 deletions(-) diff --git a/go.mod b/go.mod index 0b253c7052b9813f10b721d763ade79aba356624..6a856d069637b09e324a090dd0c3a5cac4d62ff4 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( 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.3 + github.com/charmbracelet/x/ansi v0.11.4 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 @@ -104,7 +104,7 @@ require ( github.com/charmbracelet/x/json v0.2.0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.6.2 // indirect + github.com/clipperhouse/displaywidth v0.7.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index 6346b736a83815afe4daff8390a03b941539ad6d..3a9f73bdd233aea6d5f449a8e0e27234bb519c9d 100644 --- a/go.sum +++ b/go.sum @@ -104,8 +104,8 @@ github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1Yk 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.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI= -github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI= +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/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= @@ -130,8 +130,8 @@ 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.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= -github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE= +github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index e04a781c767c5677e0165cb3e191c60532dac0e6..06183ae8142b6d7f2e4ff932cdfa07273f1a16c8 100644 --- a/internal/ui/image/image.go +++ b/internal/ui/image/image.go @@ -2,7 +2,6 @@ package image import ( "bytes" - "errors" "fmt" "hash/fnv" "image" @@ -195,50 +194,12 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i } var buf bytes.Buffer - rp, wp := io.Pipe() - go func() { - for { - // Read single Kitty graphic chunks from the pipe and wrap them - // for tmux if needed. - var out bytes.Buffer - seenEsc := false - for { - var p [1]byte - n, err := rp.Read(p[:]) - if n > 0 { - out.WriteByte(p[0]) - if p[0] == ansi.ESC { - seenEsc = true - } else if seenEsc && p[0] == '\\' { - // End of Kitty graphics sequence - break - } else { - seenEsc = false - } - } - if err != nil { - if !errors.Is(err, io.EOF) { - slog.Error("error reading from pipe", "err", err) - } - return - } - } - - seq := out.String() - if tmux { - seq = ansi.TmuxPassthrough(seq) - } - - buf.WriteString(seq) - } - }() - img := fitImage(id, img, cs, cols, rows) bounds := img.Bounds() imgWidth := bounds.Dx() imgHeight := bounds.Dy() imgID := int(key.Hash()) - if err := kitty.EncodeGraphics(wp, img, &kitty.Options{ + if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{ ID: imgID, Action: kitty.TransmitAndPut, Transmission: kitty.Direct, @@ -250,9 +211,18 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i VirtualPlacement: true, Quite: 1, Chunk: true, + ChunkFormatter: func(chunk string) string { + if tmux { + return ansi.TmuxPassthrough(chunk) + } + return chunk + }, }); err != nil { slog.Error("failed to encode image for kitty graphics", "err", err) - return uiutil.ReportError(fmt.Errorf("failed to encode image")) + return uiutil.InfoMsg{ + Type: uiutil.InfoTypeError, + Msg: "failed to encode image", + } } return tea.RawMsg{Msg: buf.String()} From cb8ddcb99c013dabc7de902e5b8ae74ba16e0abc Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 16 Jan 2026 14:38:23 -0500 Subject: [PATCH 154/335] fix(ui): filepicker: remove redundant Init method and Action type --- internal/ui/dialog/filepicker.go | 9 ++------- internal/ui/model/ui.go | 7 +++---- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index a099cd2ff9fe6f707ac1e18c8470dba55794889c..b7e7e49735d41136c4c9b9d54b8d8c4fa36a90ae 100644 --- a/internal/ui/dialog/filepicker.go +++ b/internal/ui/dialog/filepicker.go @@ -49,7 +49,7 @@ type FilePicker struct { var _ Dialog = (*FilePicker)(nil) // NewFilePicker creates a new [FilePicker] dialog. -func NewFilePicker(com *common.Common) (*FilePicker, Action) { +func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) { f := new(FilePicker) f.com = com @@ -98,7 +98,7 @@ func NewFilePicker(com *common.Common) (*FilePicker, Action) { f.fp = fp - return f, ActionCmd{f.fp.Init()} + return f, f.fp.Init() } // SetImageCapabilities sets the image capabilities for the [FilePicker]. @@ -156,11 +156,6 @@ func (f *FilePicker) ID() string { return FilePickerID } -// Init implements the [Dialog] interface. -func (f *FilePicker) Init() tea.Cmd { - return f.fp.Init() -} - // HandleMsg updates the [FilePicker] dialog based on the given message. func (f *FilePicker) HandleMsg(msg tea.Msg) Action { var cmds []tea.Cmd diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index f40a25204ce167029ee09dc1dbd0fcf012c2a94e..98f0cf7feb875597c04edd5518630736e3756ff2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2345,13 +2345,12 @@ func (m *UI) openFilesDialog() tea.Cmd { return nil } - filePicker, action := dialog.NewFilePicker(m.com) + filePicker, cmd := dialog.NewFilePicker(m.com) filePicker.SetImageCapabilities(&m.imgCaps) m.dialog.OpenDialog(filePicker) - switch action := action.(type) { - case dialog.ActionCmd: - return action.Cmd + if cmd != nil { + return cmd } return nil From f97faa180bd3274539158fe14dbc6457af579af5 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 16 Jan 2026 15:14:35 -0500 Subject: [PATCH 155/335] fix(ui): filepicker: simplify cmd return Co-authored-by: Andrey Nering --- internal/ui/model/ui.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 98f0cf7feb875597c04edd5518630736e3756ff2..581152f70ca9e2998bac8d5a593b3dd622efeea4 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2349,11 +2349,7 @@ func (m *UI) openFilesDialog() tea.Cmd { filePicker.SetImageCapabilities(&m.imgCaps) m.dialog.OpenDialog(filePicker) - if cmd != nil { - return cmd - } - - return nil + return cmd } // openPermissionsDialog opens the permissions dialog for a permission request. From e70d46fce15611861c38eeeb3ad11e42bb9fe28b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 19 Jan 2026 10:14:22 +0100 Subject: [PATCH 156/335] refactor: reasoning dialog (#1880) --- internal/ui/dialog/actions.go | 4 + internal/ui/dialog/commands.go | 5 +- internal/ui/dialog/reasoning.go | 297 ++++++++++++++++++++++++++++++++ internal/ui/model/ui.go | 92 +++++++++- internal/uiutil/uiutil.go | 32 ++-- 5 files changed, 413 insertions(+), 17 deletions(-) create mode 100644 internal/ui/dialog/reasoning.go diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index a4cfd66d695472e579fabf458ad5fc570af85b7e..b5db01692437dbee4b11b77da47b68f258b090e9 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -54,6 +54,10 @@ type ( ActionSummarize struct { SessionID string } + // ActionSelectReasoningEffort is a message indicating a reasoning effort has been selected. + ActionSelectReasoningEffort struct { + Effort string + } ActionPermissionResponse struct { Permission permission.PermissionRequest Action PermissionAction diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 97addbba1036781840951f2503b8b7813a16d9eb..9039a2457d3c86ba21886deac4137970f861fd59 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -10,6 +10,7 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/common" @@ -400,7 +401,7 @@ func (c *Commands) defaultCommands() []*CommandItem { selectedModel := cfg.Models[agentCfg.Model] // Anthropic models: thinking toggle - if providerCfg.Type == catwalk.TypeAnthropic { + if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) { status := "Enable" if selectedModel.Think { status = "Disable" @@ -411,7 +412,7 @@ func (c *Commands) defaultCommands() []*CommandItem { // OpenAI models: reasoning effort dialog if len(model.ReasoningLevels) > 0 { commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{ - // TODO: Pass in the reasoning effort dialog id + DialogID: ReasoningID, })) } } diff --git a/internal/ui/dialog/reasoning.go b/internal/ui/dialog/reasoning.go new file mode 100644 index 0000000000000000000000000000000000000000..258c5c77470380478a2ffab9af89db195c849d32 --- /dev/null +++ b/internal/ui/dialog/reasoning.go @@ -0,0 +1,297 @@ +package dialog + +import ( + "errors" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" + "github.com/sahilm/fuzzy" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +const ( + // ReasoningID is the identifier for the reasoning effort dialog. + ReasoningID = "reasoning" + reasoningDialogMaxWidth = 80 + reasoningDialogMaxHeight = 12 +) + +// Reasoning represents a dialog for selecting reasoning effort. +type Reasoning struct { + com *common.Common + help help.Model + list *list.FilterableList + input textinput.Model + + keyMap struct { + Select key.Binding + Next key.Binding + Previous key.Binding + UpDown key.Binding + Close key.Binding + } +} + +// ReasoningItem represents a reasoning effort list item. +type ReasoningItem struct { + effort string + title string + isCurrent bool + t *styles.Styles + m fuzzy.Match + cache map[int]string + focused bool +} + +var ( + _ Dialog = (*Reasoning)(nil) + _ ListItem = (*ReasoningItem)(nil) +) + +// NewReasoning creates a new reasoning effort dialog. +func NewReasoning(com *common.Common) (*Reasoning, error) { + r := &Reasoning{com: com} + + help := help.New() + help.Styles = com.Styles.DialogHelpStyles() + r.help = help + + r.list = list.NewFilterableList() + r.list.Focus() + + r.input = textinput.New() + r.input.SetVirtualCursor(false) + r.input.Placeholder = "Type to filter" + r.input.SetStyles(com.Styles.TextInput) + r.input.Focus() + + r.keyMap.Select = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ) + r.keyMap.Next = key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ) + r.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ) + r.keyMap.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ) + r.keyMap.Close = CloseKey + + if err := r.setReasoningItems(); err != nil { + return nil, err + } + + return r, nil +} + +// ID implements Dialog. +func (r *Reasoning) ID() string { + return ReasoningID +} + +// HandleMsg implements [Dialog]. +func (r *Reasoning) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, r.keyMap.Close): + return ActionClose{} + case key.Matches(msg, r.keyMap.Previous): + r.list.Focus() + if r.list.IsSelectedFirst() { + r.list.SelectLast() + r.list.ScrollToBottom() + break + } + r.list.SelectPrev() + r.list.ScrollToSelected() + case key.Matches(msg, r.keyMap.Next): + r.list.Focus() + if r.list.IsSelectedLast() { + r.list.SelectFirst() + r.list.ScrollToTop() + break + } + r.list.SelectNext() + r.list.ScrollToSelected() + case key.Matches(msg, r.keyMap.Select): + selectedItem := r.list.SelectedItem() + if selectedItem == nil { + break + } + reasoningItem, ok := selectedItem.(*ReasoningItem) + if !ok { + break + } + return ActionSelectReasoningEffort{Effort: reasoningItem.effort} + default: + var cmd tea.Cmd + r.input, cmd = r.input.Update(msg) + value := r.input.Value() + r.list.SetFilter(value) + r.list.ScrollToTop() + r.list.SetSelected(0) + return ActionCmd{cmd} + } + } + return nil +} + +// Cursor returns the cursor position relative to the dialog. +func (r *Reasoning) Cursor() *tea.Cursor { + return InputCursor(r.com.Styles, r.input.Cursor()) +} + +// Draw implements [Dialog]. +func (r *Reasoning) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := r.com.Styles + width := max(0, min(reasoningDialogMaxWidth, area.Dx())) + height := max(0, min(reasoningDialogMaxHeight, area.Dy())) + innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + + t.Dialog.HelpView.GetVerticalFrameSize() + + t.Dialog.View.GetVerticalFrameSize() + + r.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) + r.list.SetSize(innerWidth, height-heightOffset) + r.help.SetWidth(innerWidth) + + rc := NewRenderContext(t, width) + rc.Title = "Select Reasoning Effort" + inputView := t.Dialog.InputPrompt.Render(r.input.View()) + rc.AddPart(inputView) + + visibleCount := len(r.list.VisibleItems()) + if r.list.Height() >= visibleCount { + r.list.ScrollToTop() + } else { + r.list.ScrollToSelected() + } + + listView := t.Dialog.List.Height(r.list.Height()).Render(r.list.Render()) + rc.AddPart(listView) + rc.Help = r.help.View(r) + + view := rc.Render() + + cur := r.Cursor() + DrawCenterCursor(scr, area, view, cur) + return cur +} + +// ShortHelp implements [help.KeyMap]. +func (r *Reasoning) ShortHelp() []key.Binding { + return []key.Binding{ + r.keyMap.UpDown, + r.keyMap.Select, + r.keyMap.Close, + } +} + +// FullHelp implements [help.KeyMap]. +func (r *Reasoning) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := []key.Binding{ + r.keyMap.Select, + r.keyMap.Next, + r.keyMap.Previous, + r.keyMap.Close, + } + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +func (r *Reasoning) setReasoningItems() error { + cfg := r.com.Config() + agentCfg, ok := cfg.Agents[config.AgentCoder] + if !ok { + return errors.New("agent configuration not found") + } + + selectedModel := cfg.Models[agentCfg.Model] + model := cfg.GetModelByType(agentCfg.Model) + if model == nil { + return errors.New("model configuration not found") + } + + if len(model.ReasoningLevels) == 0 { + return errors.New("no reasoning levels available") + } + + currentEffort := selectedModel.ReasoningEffort + if currentEffort == "" { + currentEffort = model.DefaultReasoningEffort + } + + caser := cases.Title(language.English) + items := make([]list.FilterableItem, 0, len(model.ReasoningLevels)) + selectedIndex := 0 + for i, effort := range model.ReasoningLevels { + item := &ReasoningItem{ + effort: effort, + title: caser.String(effort), + isCurrent: effort == currentEffort, + t: r.com.Styles, + } + items = append(items, item) + if effort == currentEffort { + selectedIndex = i + } + } + + r.list.SetItems(items...) + r.list.SetSelected(selectedIndex) + r.list.ScrollToSelected() + return nil +} + +// Filter returns the filter value for the reasoning item. +func (r *ReasoningItem) Filter() string { + return r.title +} + +// ID returns the unique identifier for the reasoning effort. +func (r *ReasoningItem) ID() string { + return r.effort +} + +// SetFocused sets the focus state of the reasoning item. +func (r *ReasoningItem) SetFocused(focused bool) { + if r.focused != focused { + r.cache = nil + } + r.focused = focused +} + +// SetMatch sets the fuzzy match for the reasoning item. +func (r *ReasoningItem) SetMatch(m fuzzy.Match) { + r.cache = nil + r.m = m +} + +// Render returns the string representation of the reasoning item. +func (r *ReasoningItem) Render(width int) string { + info := "" + if r.isCurrent { + info = "current" + } + return renderItem(r.t, r.title, info, r.focused, width, r.cache, &r.m) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 581152f70ca9e2998bac8d5a593b3dd622efeea4..57253bd968b097e00e15b7c5abfe324ad64d72f4 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -925,6 +925,36 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { case dialog.ActionToggleCompactMode: cmds = append(cmds, m.toggleCompactMode()) m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionToggleThinking: + if m.com.App.AgentCoordinator.IsBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) + break + } + + cmds = append(cmds, func() tea.Msg { + cfg := m.com.Config() + if cfg == nil { + return uiutil.ReportError(errors.New("configuration not found"))() + } + + agentCfg, ok := cfg.Agents[config.AgentCoder] + if !ok { + return uiutil.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)() + } + m.com.App.UpdateAgentModel(context.TODO()) + status := "disabled" + if currentModel.Think { + status = "enabled" + } + return uiutil.NewInfoMsg("Thinking mode " + status) + }) + m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionQuit: cmds = append(cmds, tea.Quit) case dialog.ActionInitializeProject: @@ -969,15 +999,47 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { cmds = append(cmds, uiutil.ReportError(err)) } - // XXX: Should this be in a separate goroutine? - go m.com.App.UpdateAgentModel(context.TODO()) + cmds = append(cmds, func() tea.Msg { + m.com.App.UpdateAgentModel(context.TODO()) + + modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) + + return uiutil.NewInfoMsg(modelMsg) + }) - modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) - cmds = append(cmds, uiutil.ReportInfo(modelMsg)) m.dialog.CloseDialog(dialog.APIKeyInputID) m.dialog.CloseDialog(dialog.OAuthID) m.dialog.CloseDialog(dialog.ModelsID) - // TODO CHANGE + case dialog.ActionSelectReasoningEffort: + if m.com.App.AgentCoordinator.IsBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) + break + } + + cfg := m.com.Config() + if cfg == nil { + cmds = append(cmds, uiutil.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"))) + 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)) + break + } + + cmds = append(cmds, func() tea.Msg { + m.com.App.UpdateAgentModel(context.TODO()) + return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort) + }) + m.dialog.CloseDialog(dialog.ReasoningID) case dialog.ActionPermissionResponse: m.dialog.CloseDialog(dialog.PermissionsID) switch msg.Action { @@ -2248,6 +2310,10 @@ func (m *UI) openDialog(id string) tea.Cmd { if cmd := m.openCommandsDialog(); cmd != nil { cmds = append(cmds, cmd) } + case dialog.ReasoningID: + if cmd := m.openReasoningDialog(); cmd != nil { + cmds = append(cmds, cmd) + } case dialog.QuitID: if cmd := m.openQuitDialog(); cmd != nil { cmds = append(cmds, cmd) @@ -2313,6 +2379,22 @@ func (m *UI) openCommandsDialog() tea.Cmd { return nil } +// openReasoningDialog opens the reasoning effort dialog. +func (m *UI) openReasoningDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.ReasoningID) { + m.dialog.BringToFront(dialog.ReasoningID) + return nil + } + + reasoningDialog, err := dialog.NewReasoning(m.com) + if err != nil { + return uiutil.ReportError(err) + } + + m.dialog.OpenDialog(reasoningDialog) + return nil +} + // openSessionsDialog opens the sessions dialog. If the dialog is already open, // it brings it to the front. Otherwise, it will list all the sessions and open // the dialog. diff --git a/internal/uiutil/uiutil.go b/internal/uiutil/uiutil.go index 92ae8c97937793e0643cdcb6a930216116fee2dd..d0443f9c1e4b40fc23b3fb9d597a1d0cd785e1b0 100644 --- a/internal/uiutil/uiutil.go +++ b/internal/uiutil/uiutil.go @@ -26,10 +26,7 @@ func CmdHandler(msg tea.Msg) tea.Cmd { func ReportError(err error) tea.Cmd { slog.Error("Error reported", "error", err) - return CmdHandler(InfoMsg{ - Type: InfoTypeError, - Msg: err.Error(), - }) + return CmdHandler(NewErrorMsg(err)) } type InfoType int @@ -42,18 +39,33 @@ const ( InfoTypeUpdate ) -func ReportInfo(info string) tea.Cmd { - return CmdHandler(InfoMsg{ +func NewInfoMsg(info string) InfoMsg { + return InfoMsg{ Type: InfoTypeInfo, Msg: info, - }) + } } -func ReportWarn(warn string) tea.Cmd { - return CmdHandler(InfoMsg{ +func NewWarnMsg(warn string) InfoMsg { + return InfoMsg{ Type: InfoTypeWarn, Msg: warn, - }) + } +} + +func NewErrorMsg(err error) InfoMsg { + return InfoMsg{ + Type: InfoTypeError, + Msg: err.Error(), + } +} + +func ReportInfo(info string) tea.Cmd { + return CmdHandler(NewInfoMsg(info)) +} + +func ReportWarn(warn string) tea.Cmd { + return CmdHandler(NewWarnMsg(warn)) } type ( From fa8c764c4025faeab96c9e3f5eb434ec3f69f2ed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:51:20 -0300 Subject: [PATCH 157/335] chore(deps): bump the all group with 3 updates (#1918) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- .github/workflows/schema-update.yml | 2 +- .github/workflows/security.yml | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39b5923298e2f7fa8d5452327a6e8b2a08f0df97..39e104129f0c3c059b116acabb0a86f5e53e6dc4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version-file: go.mod - run: go mod tidy diff --git a/.github/workflows/schema-update.yml b/.github/workflows/schema-update.yml index 5bc1f29d91969f32757f9ad78f7742e7e20b7f3e..b02ad11ebd556490b6f2abbb4af172166a458d18 100644 --- a/.github/workflows/schema-update.yml +++ b/.github/workflows/schema-update.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version-file: go.mod - run: go run . schema > ./schema.json diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 3a90ea316c3d86f5b2f93224fd2b35eaa572e704..b0e1a82cb63e020221a32abdf9534f610ec82b43 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -30,11 +30,11 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - - uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + - uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 with: languages: ${{ matrix.language }} - - uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 - - uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + - uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 + - uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 grype: runs-on: ubuntu-latest @@ -46,13 +46,13 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - - uses: anchore/scan-action@40a61b52209e9d50e87917c5b901783d546b12d0 # v7.2.1 + - uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3 id: scan with: path: "." fail-build: true severity-cutoff: critical - - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + - uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 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@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + - uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 with: sarif_file: results.sarif From 1ca8abc0a1f24778c64091b2a0cd96e00a6b03f2 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 18 Jan 2026 17:37:33 -0500 Subject: [PATCH 158/335] chore: remove unnecessary testing concerns from env.New MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM-4.7 via Crush --- internal/env/env.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/env/env.go b/internal/env/env.go index fa7546f8ebd28ed0c44528f7d9b8ae4b03db8b89..f4bf8ff5a7ace08496a5f56d7372db91212b33fe 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -2,7 +2,6 @@ package env import ( "os" - "testing" ) type Env interface { @@ -26,9 +25,6 @@ func (o *osEnv) Env() []string { } func New() Env { - if testing.Testing() { - return NewFromMap(nil) - } return &osEnv{} } From 2f845cc20e1b01310886a7dcbd440011e80cc8d4 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 18 Jan 2026 17:41:40 -0500 Subject: [PATCH 159/335] chore: return empty slices instead of nil for safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM-4.7 via Crush --- internal/env/env.go | 9 +-------- internal/env/env_test.go | 6 ++++-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/internal/env/env.go b/internal/env/env.go index f4bf8ff5a7ace08496a5f56d7372db91212b33fe..2fe19cc20bdd8771cff98da3dd24d7b25c3cba1e 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -17,11 +17,7 @@ func (o *osEnv) Get(key string) string { } func (o *osEnv) Env() []string { - env := os.Environ() - if len(env) == 0 { - return nil - } - return env + return os.Environ() } func New() Env { @@ -42,9 +38,6 @@ func (m *mapEnv) Get(key string) string { // Env implements Env. func (m *mapEnv) Env() []string { - if len(m.m) == 0 { - return nil - } env := make([]string, 0, len(m.m)) for k, v := range m.m { env = append(env, k+"="+v) diff --git a/internal/env/env_test.go b/internal/env/env_test.go index a86b8f4140a57d564797f260b24262472c9ad058..e8e19cf8b6356e19e4f61cb3bfb2eef7b93ad9fc 100644 --- a/internal/env/env_test.go +++ b/internal/env/env_test.go @@ -90,13 +90,15 @@ func TestMapEnv_Env(t *testing.T) { t.Run("empty map", func(t *testing.T) { env := NewFromMap(map[string]string{}) envVars := env.Env() - require.Nil(t, envVars) + require.NotNil(t, envVars) + require.Len(t, envVars, 0) }) t.Run("nil map", func(t *testing.T) { env := NewFromMap(nil) envVars := env.Env() - require.Nil(t, envVars) + require.NotNil(t, envVars) + require.Len(t, envVars, 0) }) } From 36354358f302018ba173bd3f2866580ed228098a Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 18 Jan 2026 18:11:05 -0500 Subject: [PATCH 160/335] chore: remove redundant zero value initialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM-4.7 via Crush --- internal/pubsub/broker.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/pubsub/broker.go b/internal/pubsub/broker.go index ed14cbfed6c8fd44355501e16457e0dd92a494bc..d14877353819d4462f42d4e41997e92527843c98 100644 --- a/internal/pubsub/broker.go +++ b/internal/pubsub/broker.go @@ -23,7 +23,6 @@ func NewBrokerWithOptions[T any](channelBufferSize, maxEvents int) *Broker[T] { b := &Broker[T]{ subs: make(map[chan Event[T]]struct{}), done: make(chan struct{}), - subCount: 0, maxEvents: maxEvents, } return b From b7c128a33cfa79a8ae45b1865b453c7b9353a3c8 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 18 Jan 2026 18:11:27 -0500 Subject: [PATCH 161/335] chore: simplify struct initialization to direct return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM-4.7 via Crush --- internal/pubsub/broker.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/pubsub/broker.go b/internal/pubsub/broker.go index d14877353819d4462f42d4e41997e92527843c98..2faf7f89b7c982950bfc69801a7901526e37eec4 100644 --- a/internal/pubsub/broker.go +++ b/internal/pubsub/broker.go @@ -20,12 +20,11 @@ func NewBroker[T any]() *Broker[T] { } func NewBrokerWithOptions[T any](channelBufferSize, maxEvents int) *Broker[T] { - b := &Broker[T]{ + return &Broker[T]{ subs: make(map[chan Event[T]]struct{}), done: make(chan struct{}), maxEvents: maxEvents, } - return b } func (b *Broker[T]) Shutdown() { From 4261dfbf156ae6c82e5e0d534eacd2651bd1f896 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 18 Jan 2026 18:13:19 -0500 Subject: [PATCH 162/335] refactor: move domain-specific type out of generic pubsub package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM-4.7 via Crush --- internal/app/app.go | 9 ++++++++- internal/pubsub/events.go | 7 ------- internal/tui/tui.go | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 0f98a8383124274d8aaae12b40146411ed969c8d..96750a453e63c79b7363fef5b3aa9b09632de940 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -40,6 +40,13 @@ import ( "github.com/charmbracelet/x/term" ) +// UpdateAvailableMsg is sent when a new version is available. +type UpdateAvailableMsg struct { + CurrentVersion string + LatestVersion string + IsDevelopment bool +} + type App struct { Sessions session.Service Messages message.Service @@ -452,7 +459,7 @@ func (app *App) checkForUpdates(ctx context.Context) { if err != nil || !info.Available() { return } - app.events <- pubsub.UpdateAvailableMsg{ + app.events <- UpdateAvailableMsg{ CurrentVersion: info.Current, LatestVersion: info.Latest, IsDevelopment: info.IsDevelopment(), diff --git a/internal/pubsub/events.go b/internal/pubsub/events.go index 016cc10c9f8a51039ce9eeda6210f5f59bdc1e6c..827158d52fd671aeda828c0383fce98850e27fc7 100644 --- a/internal/pubsub/events.go +++ b/internal/pubsub/events.go @@ -26,10 +26,3 @@ type ( Publish(EventType, T) } ) - -// UpdateAvailableMsg is sent when a new version is available. -type UpdateAvailableMsg struct { - CurrentVersion string - LatestVersion string - IsDevelopment bool -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e91fae5592b8d51963e524d0662d868cbfed6869..519b0e6aca73fb276d4137e00830b29798a391c7 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -385,7 +385,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, tea.Batch(cmds...) // Update Available - case pubsub.UpdateAvailableMsg: + 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 { From 2fa1f4dd56d9edced17df1942973d925a4d6cbb8 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 18 Jan 2026 18:41:06 -0500 Subject: [PATCH 163/335] fix: correct spelling from marshall to marshal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM-4.7 via Crush --- internal/message/message.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/message/message.go b/internal/message/message.go index a09d0acbf590e840541a7d5e057fb89513cc0618..04eb8252bbe9a68444eba81fc581c6b49231734b 100644 --- a/internal/message/message.go +++ b/internal/message/message.go @@ -63,7 +63,7 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes Reason: "stop", }) } - partsJSON, err := marshallParts(params.Parts) + partsJSON, err := marshalParts(params.Parts) if err != nil { return Message{}, err } @@ -110,7 +110,7 @@ func (s *service) DeleteSessionMessages(ctx context.Context, sessionID string) e } func (s *service) Update(ctx context.Context, message Message) error { - parts, err := marshallParts(message.Parts) + parts, err := marshalParts(message.Parts) if err != nil { return err } @@ -158,7 +158,7 @@ func (s *service) List(ctx context.Context, sessionID string) ([]Message, error) } func (s *service) fromDBItem(item db.Message) (Message, error) { - parts, err := unmarshallParts([]byte(item.Parts)) + parts, err := unmarshalParts([]byte(item.Parts)) if err != nil { return Message{}, err } @@ -192,7 +192,7 @@ type partWrapper struct { Data ContentPart `json:"data"` } -func marshallParts(parts []ContentPart) ([]byte, error) { +func marshalParts(parts []ContentPart) ([]byte, error) { wrappedParts := make([]partWrapper, len(parts)) for i, part := range parts { @@ -225,7 +225,7 @@ func marshallParts(parts []ContentPart) ([]byte, error) { return json.Marshal(wrappedParts) } -func unmarshallParts(data []byte) ([]ContentPart, error) { +func unmarshalParts(data []byte) ([]ContentPart, error) { temp := []json.RawMessage{} if err := json.Unmarshal(data, &temp); err != nil { From 4403170ac0af2eb076d23fa9ed4d85ee667cf98d Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 18 Jan 2026 18:44:11 -0500 Subject: [PATCH 164/335] fix: correct typo in log message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM-4.7 via Crush --- internal/lsp/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 79220cc1f315fec30a1bee2aa0dcd106bc311a02..df28a30bbcce9504fb8a4a0eaba98a820028e705 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -345,7 +345,7 @@ func (c *Client) CloseAllFiles(ctx context.Context) { slog.Debug("Closing file", "file", uri) } if err := c.client.NotifyDidCloseTextDocument(ctx, uri); err != nil { - slog.Warn("Error closing rile", "uri", uri, "error", err) + slog.Warn("Error closing file", "uri", uri, "error", err) continue } c.openFiles.Del(uri) From 0decf78c95af8e3acbe6a9e056a179afa0a38c15 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 20 Jan 2026 15:39:10 +0100 Subject: [PATCH 165/335] refactor: add assistant info item (#1881) --- internal/ui/AGENTS.md | 2 + internal/ui/chat/messages.go | 85 ++++++++++++++++++++++++++++- internal/ui/common/elements.go | 32 ++++++++--- internal/ui/model/chat.go | 97 +++++++++++++++++++++++++++++++--- internal/ui/model/sidebar.go | 33 +++++++----- internal/ui/model/ui.go | 58 ++++++++++++++++++-- internal/ui/styles/styles.go | 8 +++ 7 files changed, 287 insertions(+), 28 deletions(-) diff --git a/internal/ui/AGENTS.md b/internal/ui/AGENTS.md index 6a5ce8eb2a6104e6462d57dd52e47399bc9cc773..7fce65ce12d69d2d1be0268c9acbd45fd7605851 100644 --- a/internal/ui/AGENTS.md +++ b/internal/ui/AGENTS.md @@ -4,6 +4,8 @@ - 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 change the model state inside of a command use messages and than update the state in the main loop ## Architecture diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index d2e655d57ab7549411a1fa2d23f5beb52d4f92cd..68954b0f3f0168b9da91b1b28db1b5101e5f9c3b 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -4,13 +4,19 @@ package chat import ( + "fmt" "image" "strings" + "time" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/attachments" + "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" ) @@ -42,9 +48,19 @@ type Expandable interface { // UI and be part of a [list.List] identifiable by a unique ID. type MessageItem interface { list.Item + Identifiable +} + +// HighlightableMessageItem is a message item that supports highlighting. +type HighlightableMessageItem interface { + MessageItem list.Highlightable +} + +// FocusableMessageItem is a message item that supports focus. +type FocusableMessageItem interface { + MessageItem list.Focusable - Identifiable } // SendMsg represents a message to send a chat message. @@ -146,6 +162,73 @@ func (f *focusableMessageItem) SetFocused(focused bool) { f.focused = focused } +// AssistantInfoID returns a stable ID for assistant info items. +func AssistantInfoID(messageID string) string { + return fmt.Sprintf("%s:assistant-info", messageID) +} + +// AssistantInfoItem renders model info and response time after assistant completes. +type AssistantInfoItem struct { + *cachedMessageItem + + id string + message *message.Message + sty *styles.Styles + lastUserMessageTime time.Time +} + +// NewAssistantInfoItem creates a new AssistantInfoItem. +func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, lastUserMessageTime time.Time) MessageItem { + return &AssistantInfoItem{ + cachedMessageItem: &cachedMessageItem{}, + id: AssistantInfoID(message.ID), + message: message, + sty: sty, + lastUserMessageTime: lastUserMessageTime, + } +} + +// ID implements MessageItem. +func (a *AssistantInfoItem) ID() string { + return a.id +} + +// Render implements MessageItem. +func (a *AssistantInfoItem) Render(width int) string { + innerWidth := max(0, width-messageLeftPaddingTotal) + content, _, ok := a.getCachedRender(innerWidth) + if !ok { + content = a.renderContent(innerWidth) + height := lipgloss.Height(content) + a.setCachedRender(content, innerWidth, height) + } + + return a.sty.Chat.Message.SectionHeader.Render(content) +} + +func (a *AssistantInfoItem) renderContent(width int) string { + finishData := a.message.FinishPart() + if finishData == nil { + return "" + } + finishTime := time.Unix(finishData.Time, 0) + 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) + 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 { + providerName = providerConfig.Name + } + provider := a.sty.Chat.Message.AssistantInfoProvider.Render(fmt.Sprintf("via %s", providerName)) + assistant := fmt.Sprintf("%s %s %s %s", icon, modelFormatted, provider, infoMsg) + 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) diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index f2256676d47b5f5b706ca05e7e4d5a21d6d5867a..ccb7f7cdb2677980ddac4a55e153354c9f220962 100644 --- a/internal/ui/common/elements.go +++ b/internal/ui/common/elements.go @@ -26,15 +26,35 @@ type ModelContextInfo struct { Cost float64 } -// ModelInfo renders model information including name, reasoning settings, and -// optional context usage/cost. -func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context *ModelContextInfo, width int) string { +// ModelInfo renders model information including name, provider, reasoning +// settings, and optional context usage/cost. +func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string, context *ModelContextInfo, width int) string { modelIcon := t.Subtle.Render(styles.ModelIcon) modelName = t.Base.Render(modelName) - modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName) - parts := []string{ - modelInfo, + // Build first line with model name and optionally provider on the same line + var firstLine string + if providerName != "" { + providerInfo := t.Muted.Render(fmt.Sprintf("via %s", providerName)) + modelWithProvider := fmt.Sprintf("%s %s %s", modelIcon, modelName, providerInfo) + + // Check if it fits on one line + if lipgloss.Width(modelWithProvider) <= width { + firstLine = modelWithProvider + } else { + // If it doesn't fit, put provider on next line + firstLine = fmt.Sprintf("%s %s", modelIcon, modelName) + } + } else { + firstLine = fmt.Sprintf("%s %s", modelIcon, modelName) + } + + parts := []string{firstLine} + + // If provider didn't fit on first line, add it as second line + if providerName != "" && !strings.Contains(firstLine, "via") { + providerInfo := fmt.Sprintf("via %s", providerName) + parts = append(parts, t.Muted.PaddingLeft(2).Render(providerInfo)) } if reasoningInfo != "" { diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 76a82a7d7242b4b089381685763750ee762c043c..a6c8fb1cf213be37c8f095ba776f936bec96b57a 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -238,39 +238,121 @@ func (m *Chat) SelectedItemInView() bool { return m.list.SelectedItemInView() } +func (m *Chat) isSelectable(index int) bool { + item := m.list.ItemAt(index) + if item == nil { + return false + } + _, ok := item.(list.Focusable) + return ok +} + // SetSelected sets the selected message index in the chat list. func (m *Chat) SetSelected(index int) { m.list.SetSelected(index) + if index < 0 || index >= m.list.Len() { + return + } + for { + if m.isSelectable(m.list.Selected()) { + return + } + if m.list.SelectNext() { + continue + } + // If we're at the end and the last item isn't selectable, walk backwards + // to find the nearest selectable item. + for { + if !m.list.SelectPrev() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } + } } // SelectPrev selects the previous message in the chat list. func (m *Chat) SelectPrev() { - m.list.SelectPrev() + for { + if !m.list.SelectPrev() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } } // SelectNext selects the next message in the chat list. func (m *Chat) SelectNext() { - m.list.SelectNext() + for { + if !m.list.SelectNext() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } } // SelectFirst selects the first message in the chat list. func (m *Chat) SelectFirst() { - m.list.SelectFirst() + if !m.list.SelectFirst() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + for { + if !m.list.SelectNext() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } } // SelectLast selects the last message in the chat list. func (m *Chat) SelectLast() { - m.list.SelectLast() + if !m.list.SelectLast() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + for { + if !m.list.SelectPrev() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } } // SelectFirstInView selects the first message currently in view. func (m *Chat) SelectFirstInView() { - m.list.SelectFirstInView() + startIdx, endIdx := m.list.VisibleItemIndices() + for i := startIdx; i <= endIdx; i++ { + if m.isSelectable(i) { + m.list.SetSelected(i) + return + } + } } // SelectLastInView selects the last message currently in view. func (m *Chat) SelectLastInView() { - m.list.SelectLastInView() + startIdx, endIdx := m.list.VisibleItemIndices() + for i := endIdx; i >= startIdx; i-- { + if m.isSelectable(i) { + m.list.SetSelected(i) + return + } + } } // ClearMessages removes all messages from the chat list. @@ -335,6 +417,9 @@ func (m *Chat) HandleMouseDown(x, y int) bool { if itemIdx < 0 { return false } + if !m.isSelectable(itemIdx) { + return false + } m.mouseDown = true m.mouseDownItem = itemIdx diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index c0e46eb31530bc9b9d4f62fbfb020afdd7abc009..2437fab9177b9186cfcd4c185c45c48204cea7d9 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -18,23 +18,32 @@ import ( func (m *UI) modelInfo(width int) string { model := m.selectedLargeModel() reasoningInfo := "" - if model != nil && model.CatwalkCfg.CanReason { + providerName := "" + + if model != nil { + // Get provider name first providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider) if ok { - switch providerConfig.Type { - case catwalk.TypeAnthropic: - if model.ModelCfg.Think { - reasoningInfo = "Thinking On" - } else { - reasoningInfo = "Thinking Off" + providerName = providerConfig.Name + + // Only check reasoning if model can reason + if model.CatwalkCfg.CanReason { + switch providerConfig.Type { + case catwalk.TypeAnthropic: + if model.ModelCfg.Think { + reasoningInfo = "Thinking On" + } else { + reasoningInfo = "Thinking Off" + } + default: + formatter := cases.Title(language.English, cases.NoLower) + reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort) + reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort)) } - default: - formatter := cases.Title(language.English, cases.NoLower) - reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort) - reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort)) } } } + var modelContext *common.ModelContextInfo if m.session != nil { modelContext = &common.ModelContextInfo{ @@ -43,7 +52,7 @@ func (m *UI) modelInfo(width int) string { ModelContext: model.CatwalkCfg.ContextWindow, } } - return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, reasoningInfo, modelContext, width) + return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width) } // getDynamicHeightLimits will give us the num of items to show in each section based on the hight diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 57253bd968b097e00e15b7c5abfe324ad64d72f4..46f4b658f7509c899dfa7f35f164f34f27a1ea43 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -110,6 +110,8 @@ type UI struct { session *session.Session sessionFiles []SessionFile + lastUserMessageTime int64 + // The width and height of the terminal in cells. width int height int @@ -596,11 +598,26 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { msgPtrs[i] = &msgs[i] } toolResultMap := chat.BuildToolResultMap(msgPtrs) + if len(msgPtrs) > 0 { + m.lastUserMessageTime = msgPtrs[0].CreatedAt + } // Add messages to chat with linked tool results items := make([]chat.MessageItem, 0, len(msgs)*2) for _, msg := range msgPtrs { - items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...) + switch msg.Role { + case message.User: + m.lastUserMessageTime = msg.CreatedAt + items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...) + 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)) + items = append(items, infoItem) + } + default: + items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...) + } } // Load nested tool calls for agent/agentic_fetch tools. @@ -692,7 +709,21 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { return nil } switch msg.Role { - case message.User, message.Assistant: + case message.User: + m.lastUserMessageTime = msg.CreatedAt + items := chat.ExtractMessageItems(m.com.Styles, &msg, nil) + for _, item := range items { + if animatable, ok := item.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + m.chat.AppendMessages(items...) + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + case message.Assistant: items := chat.ExtractMessageItems(m.com.Styles, &msg, nil) for _, item := range items { if animatable, ok := item.(chat.Animatable); ok { @@ -705,6 +736,13 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } + if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { + infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0)) + m.chat.AppendMessages(infoItem) + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } case message.Tool: for _, tr := range msg.ToolResults() { toolItem := m.chat.MessageItem(tr.ToolCallID) @@ -733,9 +771,23 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { } } + shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg) // if the message of the assistant does not have any response just tool calls we need to remove it - if !chat.ShouldRenderAssistantMessage(&msg) && len(msg.ToolCalls()) > 0 && existingItem != nil { + if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil { m.chat.RemoveMessage(msg.ID) + if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil { + m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID)) + } + } + + 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)) + m.chat.AppendMessages(newInfoItem) + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } } var items []chat.MessageItem diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 97711efaa7b951f13aa01a0a823ee7514919b136..42cf3c8dbd44f8983f588bc303ef7ae142e71a70 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -238,6 +238,10 @@ type Styles struct { ThinkingTruncationHint lipgloss.Style // "… (N lines hidden)" hint ThinkingFooterTitle lipgloss.Style // "Thought for" text ThinkingFooterDuration lipgloss.Style // Duration value + AssistantInfoIcon lipgloss.Style + AssistantInfoModel lipgloss.Style + AssistantInfoProvider lipgloss.Style + AssistantInfoDuration lipgloss.Style } } @@ -1193,6 +1197,10 @@ func DefaultStyles() Styles { // No padding or border for compact tool calls within messages s.Chat.Message.ToolCallCompact = s.Muted s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2) + s.Chat.Message.AssistantInfoIcon = s.Subtle + s.Chat.Message.AssistantInfoModel = s.Muted + s.Chat.Message.AssistantInfoProvider = s.Subtle + s.Chat.Message.AssistantInfoDuration = s.Subtle // Thinking section styles s.Chat.Message.ThinkingBox = s.Subtle.Background(bgBaseLighter) From c3e9196ed126e12d675092f02f3ddd4f42f8001b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 20 Jan 2026 15:42:52 +0100 Subject: [PATCH 166/335] refactor: pills section (#1916) --- internal/ui/chat/todos.go | 8 +- internal/ui/model/keys.go | 15 ++ internal/ui/model/pills.go | 283 +++++++++++++++++++++++++++++++++++ internal/ui/model/ui.go | 179 ++++++++++++++++++---- internal/ui/styles/styles.go | 24 ++- 5 files changed, 479 insertions(+), 30 deletions(-) create mode 100644 internal/ui/model/pills.go diff --git a/internal/ui/chat/todos.go b/internal/ui/chat/todos.go index f34e10a093b2b66d4b9993237fdbfe94fb53ecfb..5678d0e47f4c3a808c13c1dc6209f9194e9f9482 100644 --- a/internal/ui/chat/todos.go +++ b/internal/ui/chat/todos.go @@ -82,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, cappedWidth) + body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth) } else { // Build header based on what changed. hasCompleted := len(meta.JustCompleted) > 0 @@ -108,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, cappedWidth) + body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth) } else if meta.JustStarted != "" { body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") + sty.Base.Render(meta.JustStarted) @@ -135,8 +135,8 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts return joinToolParts(header, sty.Tool.Body.Render(body)) } -// formatTodosList formats a list of todos for display. -func formatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string { +// FormatTodosList formats a list of todos for display. +func FormatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string { if len(todos) == 0 { return "" } diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index f2f3fc9106c92effe38b48dd6f664cb8617f9443..053c30aaa1b51b1fd04bc8a3e754460519336359 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -23,6 +23,9 @@ type KeyMap struct { Cancel key.Binding Tab key.Binding Details key.Binding + TogglePills key.Binding + PillLeft key.Binding + PillRight key.Binding Down key.Binding Up key.Binding UpDown key.Binding @@ -149,6 +152,18 @@ func DefaultKeyMap() KeyMap { key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "toggle details"), ) + km.Chat.TogglePills = key.NewBinding( + key.WithKeys("ctrl+space"), + key.WithHelp("ctrl+space", "toggle tasks"), + ) + km.Chat.PillLeft = key.NewBinding( + key.WithKeys("left"), + key.WithHelp("←/→", "switch section"), + ) + km.Chat.PillRight = key.NewBinding( + key.WithKeys("right"), + key.WithHelp("←/→", "switch section"), + ) km.Chat.Down = key.NewBinding( key.WithKeys("down", "ctrl+j", "ctrl+n", "j"), diff --git a/internal/ui/model/pills.go b/internal/ui/model/pills.go new file mode 100644 index 0000000000000000000000000000000000000000..7662b10cc61c19b5333f7487747354341e35aa99 --- /dev/null +++ b/internal/ui/model/pills.go @@ -0,0 +1,283 @@ +package model + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/chat" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// pillStyle returns the appropriate style for a pill based on focus state. +func pillStyle(focused, panelFocused bool, t *styles.Styles) lipgloss.Style { + if !panelFocused || focused { + return t.Pills.Focused + } + return t.Pills.Blurred +} + +const ( + // pillHeightWithBorder is the height of a pill including its border. + pillHeightWithBorder = 3 + // maxTaskDisplayLength is the maximum length of a task name in the pill. + maxTaskDisplayLength = 40 + // maxQueueDisplayLength is the maximum length of a queue item in the list. + maxQueueDisplayLength = 60 +) + +// pillSection represents which section of the pills panel is focused. +type pillSection int + +const ( + pillSectionTodos pillSection = iota + pillSectionQueue +) + +// hasIncompleteTodos returns true if there are any non-completed todos. +func hasIncompleteTodos(todos []session.Todo) bool { + for _, todo := range todos { + if todo.Status != session.TodoStatusCompleted { + return true + } + } + return false +} + +// hasInProgressTodo returns true if there is at least one in-progress todo. +func hasInProgressTodo(todos []session.Todo) bool { + for _, todo := range todos { + if todo.Status == session.TodoStatusInProgress { + return true + } + } + return false +} + +// queuePill renders the queue count pill with gradient triangles. +func queuePill(queue int, focused, panelFocused bool, t *styles.Styles) string { + if queue <= 0 { + return "" + } + triangles := styles.ForegroundGrad(t, "▶▶▶▶▶▶▶▶▶", false, t.RedDark, t.Secondary) + if queue < len(triangles) { + triangles = triangles[:queue] + } + + content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue) + return pillStyle(focused, panelFocused, t).Render(content) +} + +// todoPill renders the todo progress pill with optional spinner and task name. +func todoPill(todos []session.Todo, spinnerView string, focused, panelFocused bool, t *styles.Styles) 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 := t.Base.Render("To-Do") + progress := t.Muted.Render(fmt.Sprintf("%d/%d", completed, total)) + + var content string + if panelFocused { + 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.Subtle.Render(taskText) + content = fmt.Sprintf("%s %s %s %s", spinnerView, label, progress, task) + } else { + content = fmt.Sprintf("%s %s", label, progress) + } + + return pillStyle(focused, panelFocused, t).Render(content) +} + +// todoList renders the expanded todo list. +func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Styles, width int) string { + return chat.FormatTodosList(t, sessionTodos, spinnerView, width) +} + +// queueList renders the expanded queue items list. +func queueList(queueItems []string, t *styles.Styles) 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.Pills.QueueItemPrefix.Render() + " " + lines = append(lines, prefix+t.Muted.Render(text)) + } + + return strings.Join(lines, "\n") +} + +// togglePillsExpanded toggles the pills panel expansion state. +func (m *UI) togglePillsExpanded() tea.Cmd { + if !m.hasSession() { + return nil + } + if m.layout.pills.Dy() > 0 { + if cmd := m.chat.ScrollByAndAnimate(0); cmd != nil { + return cmd + } + } + hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0 + if !hasPills { + return nil + } + m.pillsExpanded = !m.pillsExpanded + if m.pillsExpanded { + if hasIncompleteTodos(m.session.Todos) { + m.focusedPillSection = pillSectionTodos + } else { + m.focusedPillSection = pillSectionQueue + } + } + m.updateLayoutAndSize() + return nil +} + +// switchPillSection changes focus between todo and queue sections. +func (m *UI) switchPillSection(dir int) tea.Cmd { + if !m.pillsExpanded || !m.hasSession() { + return nil + } + hasIncompleteTodos := hasIncompleteTodos(m.session.Todos) + hasQueue := m.promptQueue > 0 + + if dir < 0 && m.focusedPillSection == pillSectionQueue && hasIncompleteTodos { + m.focusedPillSection = pillSectionTodos + m.updateLayoutAndSize() + return nil + } + if dir > 0 && m.focusedPillSection == pillSectionTodos && hasQueue { + m.focusedPillSection = pillSectionQueue + m.updateLayoutAndSize() + return nil + } + return nil +} + +// pillsAreaHeight calculates the total height needed for the pills area. +func (m *UI) pillsAreaHeight() int { + if !m.hasSession() { + return 0 + } + hasIncomplete := hasIncompleteTodos(m.session.Todos) + hasQueue := m.promptQueue > 0 + hasPills := hasIncomplete || hasQueue + if !hasPills { + return 0 + } + + pillsAreaHeight := pillHeightWithBorder + if m.pillsExpanded { + if m.focusedPillSection == pillSectionTodos && hasIncomplete { + pillsAreaHeight += len(m.session.Todos) + } else if m.focusedPillSection == pillSectionQueue && hasQueue { + pillsAreaHeight += m.promptQueue + } + } + return pillsAreaHeight +} + +// renderPills renders the pills panel and stores it in m.pillsView. +func (m *UI) renderPills() { + m.pillsView = "" + if !m.hasSession() { + return + } + + width := m.layout.pills.Dx() + if width <= 0 { + return + } + + paddingLeft := 3 + contentWidth := max(width-paddingLeft, 0) + + hasIncomplete := hasIncompleteTodos(m.session.Todos) + hasQueue := m.promptQueue > 0 + + if !hasIncomplete && !hasQueue { + return + } + + t := m.com.Styles + todosFocused := m.pillsExpanded && m.focusedPillSection == pillSectionTodos + queueFocused := m.pillsExpanded && m.focusedPillSection == pillSectionQueue + + inProgressIcon := t.Tool.TodoInProgressIcon.Render(styles.SpinnerIcon) + if m.todoIsSpinning { + inProgressIcon = m.todoSpinner.View() + } + + var pills []string + if hasIncomplete { + pills = append(pills, todoPill(m.session.Todos, inProgressIcon, todosFocused, m.pillsExpanded, t)) + } + if hasQueue { + pills = append(pills, queuePill(m.promptQueue, queueFocused, m.pillsExpanded, t)) + } + + var expandedList string + if m.pillsExpanded { + if todosFocused && hasIncomplete { + expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth) + } else if queueFocused && hasQueue { + if m.com.App != nil && m.com.App.AgentCoordinator != nil { + queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID) + expandedList = queueList(queueItems, t) + } + } + } + + if len(pills) == 0 { + return + } + + pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...) + + helpDesc := "open" + if m.pillsExpanded { + helpDesc = "close" + } + helpKey := t.Pills.HelpKey.Render("ctrl+space") + helpText := t.Pills.HelpText.Render(helpDesc) + helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText) + pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint) + + pillsArea := pillsRow + if expandedList != "" { + pillsArea = lipgloss.JoinVertical(lipgloss.Left, pillsRow, expandedList) + } + + m.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 46f4b658f7509c899dfa7f35f164f34f27a1ea43..b4c173781560aee26755eefe4d5694fb44ec8807 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -189,6 +189,16 @@ type UI struct { // detailsOpen tracks whether the details panel is open (in compact mode) detailsOpen bool + + // pills state + pillsExpanded bool + focusedPillSection pillSection + promptQueue int + pillsView string + + // Todo spinner + todoSpinner spinner.Model + todoIsSpinning bool } // New creates a new instance of the [UI] model. @@ -212,6 +222,11 @@ func New(com *common.Common) *UI { com.Styles.Completions.Match, ) + todoSpinner := spinner.New( + spinner.WithSpinner(spinner.MiniDot), + spinner.WithStyle(com.Styles.Pills.TodoSpinner), + ) + // Attachments component attachments := attachments.New( attachments.NewRenderer( @@ -237,6 +252,7 @@ func New(com *common.Common) *UI { chat: ch, completions: comp, attachments: attachments, + todoSpinner: todoSpinner, } status := NewStatus(com, ui) @@ -312,6 +328,13 @@ func (m *UI) loadMCPrompts() tea.Cmd { // Update handles updates to the UI model. func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd + if m.hasSession() && m.isAgentBusy() { + queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) + if queueSize != m.promptQueue { + m.promptQueue = queueSize + m.updateLayoutAndSize() + } + } switch msg := msg.(type) { case tea.EnvMsg: // Is this Windows Terminal? @@ -333,6 +356,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd := m.setSessionMessages(msgs); cmd != nil { cmds = append(cmds, cmd) } + if hasInProgressTodo(m.session.Todos) { + // only start spinner if there is an in-progress todo + if m.isAgentBusy() { + m.todoIsSpinning = true + cmds = append(cmds, m.todoSpinner.Tick) + } + m.updateLayoutAndSize() + } case sendMessageMsg: cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...)) @@ -363,6 +394,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case closeDialogMsg: m.dialog.CloseFrontDialog() + case pubsub.Event[session.Session]: + if m.session != nil && msg.Payload.ID == m.session.ID { + prevHasInProgress := hasInProgressTodo(m.session.Todos) + m.session = &msg.Payload + if !prevHasInProgress && hasInProgressTodo(m.session.Todos) { + m.todoIsSpinning = true + cmds = append(cmds, m.todoSpinner.Tick) + m.updateLayoutAndSize() + } + } case pubsub.Event[message.Message]: // Check if this is a child session message for an agent tool. if m.session == nil { @@ -383,6 +424,17 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case pubsub.DeletedEvent: m.chat.RemoveMessage(msg.Payload.ID) } + // start the spinner if there is a new message + if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning { + m.todoIsSpinning = true + cmds = append(cmds, m.todoSpinner.Tick) + } + // stop the spinner if the agent is not busy anymore + if m.todoIsSpinning && !m.isAgentBusy() { + m.todoIsSpinning = false + } + // there is a number of things that could change the pills here so we want to re-render + m.renderPills() case pubsub.Event[history.File]: cmds = append(cmds, m.handleFileEvent(msg.Payload)) case pubsub.Event[app.LSPEvent]: @@ -524,6 +576,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } } + if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning { + var cmd tea.Cmd + m.todoSpinner, cmd = m.todoSpinner.Update(msg) + if cmd != nil { + m.renderPills() + cmds = append(cmds, cmd) + } + } case tea.KeyPressMsg: if cmd := m.handleKeyPressMsg(msg); cmd != nil { @@ -573,7 +633,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case uiFocusMain: case uiFocusEditor: // Textarea placeholder logic - if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + if m.isAgentBusy() { m.textarea.Placeholder = m.workingPlaceholder } else { m.textarea.Placeholder = m.readyPlaceholder @@ -945,14 +1005,14 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { m.setEditorPrompt(yolo) m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionNewSession: - if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + if m.isAgentBusy() { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) break } m.newSession() m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionSummarize: - if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + if m.isAgentBusy() { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session...")) break } @@ -968,7 +1028,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { m.status.ToggleHelp() m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionExternalEditor: - if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) { + if m.isAgentBusy() { cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait...")) break } @@ -978,7 +1038,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { cmds = append(cmds, m.toggleCompactMode()) m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionToggleThinking: - if m.com.App.AgentCoordinator.IsBusy() { + if m.isAgentBusy() { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) break } @@ -1010,14 +1070,14 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { case dialog.ActionQuit: cmds = append(cmds, tea.Quit) case dialog.ActionInitializeProject: - if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + if m.isAgentBusy() { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session...")) break } cmds = append(cmds, m.initializeProject()) case dialog.ActionSelectModel: - if m.com.App.AgentCoordinator.IsBusy() { + if m.isAgentBusy() { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) break } @@ -1063,7 +1123,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { m.dialog.CloseDialog(dialog.OAuthID) m.dialog.CloseDialog(dialog.ModelsID) case dialog.ActionSelectReasoningEffort: - if m.com.App.AgentCoordinator.IsBusy() { + if m.isAgentBusy() { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) break } @@ -1217,6 +1277,27 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.detailsOpen = !m.detailsOpen m.updateLayoutAndSize() return true + case key.Matches(msg, m.keyMap.Chat.TogglePills): + if m.state == uiChat && m.hasSession() { + if cmd := m.togglePillsExpanded(); cmd != nil { + cmds = append(cmds, cmd) + } + return true + } + case key.Matches(msg, m.keyMap.Chat.PillLeft): + if m.state == uiChat && m.hasSession() && m.pillsExpanded { + if cmd := m.switchPillSection(-1); cmd != nil { + cmds = append(cmds, cmd) + } + return true + } + case key.Matches(msg, m.keyMap.Chat.PillRight): + if m.state == uiChat && m.hasSession() && m.pillsExpanded { + if cmd := m.switchPillSection(1); cmd != nil { + cmds = append(cmds, cmd) + } + return true + } } return false } @@ -1237,7 +1318,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { // Handle cancel key when agent is busy. if key.Matches(msg, m.keyMap.Chat.Cancel) { - if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + if m.isAgentBusy() { if cmd := m.cancelAgent(); cmd != nil { cmds = append(cmds, cmd) } @@ -1309,10 +1390,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { return m.sendMessage(value, attachments...) case key.Matches(msg, m.keyMap.Chat.NewSession): - if m.session == nil || m.session.ID == "" { + if !m.hasSession() { break } - if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + if m.isAgentBusy() { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) break } @@ -1323,7 +1404,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.chat.Focus() m.chat.SetSelected(m.chat.Len() - 1) case key.Matches(msg, m.keyMap.Editor.OpenEditor): - if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) { + if m.isAgentBusy() { cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait...")) break } @@ -1518,6 +1599,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { } m.chat.Draw(scr, layout.main) + if layout.pills.Dy() > 0 && m.pillsView != "" { + uv.NewStyledString(m.pillsView).Draw(scr, layout.pills) + } editorWidth := scr.Bounds().Dx() if !m.isCompact { @@ -1617,7 +1701,7 @@ func (m *UI) View() tea.View { content = strings.Join(contentLines, "\n") v.Content = content - if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + if m.sendProgressBar && m.isAgentBusy() { // HACK: use a random percentage to prevent ghostty from hiding it // after a timeout. v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100)) @@ -1641,7 +1725,7 @@ func (m *UI) ShortHelp() []key.Binding { binds = append(binds, k.Quit) case uiChat: // Show cancel binding if agent is busy. - if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + if m.isAgentBusy() { cancelBinding := k.Chat.Cancel if m.isCanceling { cancelBinding.SetHelp("esc", "press again to cancel") @@ -1676,6 +1760,9 @@ func (m *UI) ShortHelp() []key.Binding { k.Chat.PageDown, k.Chat.Copy, ) + if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 { + binds = append(binds, k.Chat.PillLeft) + } } default: // TODO: other states @@ -1703,7 +1790,7 @@ func (m *UI) FullHelp() [][]key.Binding { help := k.Help help.SetHelp("ctrl+g", "less") hasAttachments := len(m.attachments.List()) > 0 - hasSession := m.session != nil && m.session.ID != "" + hasSession := m.hasSession() commands := k.Commands if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 { commands.SetHelp("/ or ctrl+p", "commands") @@ -1717,7 +1804,7 @@ func (m *UI) FullHelp() [][]key.Binding { }) case uiChat: // Show cancel binding if agent is busy. - if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + if m.isAgentBusy() { cancelBinding := k.Chat.Cancel if m.isCanceling { cancelBinding.SetHelp("esc", "press again to cancel") @@ -1785,6 +1872,9 @@ func (m *UI) FullHelp() [][]key.Binding { k.Chat.ClearHighlight, }, ) + if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 { + binds = append(binds, []key.Binding{k.Chat.PillLeft}) + } } default: if m.session == nil { @@ -1873,6 +1963,7 @@ func (m *UI) updateSize() { m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy()) m.textarea.SetWidth(m.layout.editor.Dx()) m.textarea.SetHeight(m.layout.editor.Dy()) + m.renderPills() // Handle different app states switch m.state { @@ -1984,10 +2075,18 @@ func (m *UI) generateLayout(w, h int) layout { mainRect.Min.Y += 1 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) mainRect.Max.X -= 1 // Add padding right - // Add bottom margin to main - mainRect.Max.Y -= 1 layout.header = headerRect - layout.main = mainRect + 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 + } else { + layout.main = mainRect + } + // Add bottom margin to main + layout.main.Max.Y -= 1 layout.editor = editorRect } else { // Layout @@ -2004,10 +2103,18 @@ func (m *UI) generateLayout(w, h int) layout { sideRect.Min.X += 1 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) mainRect.Max.X -= 1 // Add padding right - // Add bottom margin to main - mainRect.Max.Y -= 1 layout.sidebar = sideRect - layout.main = mainRect + 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 + } else { + layout.main = mainRect + } + // Add bottom margin to main + layout.main.Max.Y -= 1 layout.editor = editorRect } } @@ -2035,6 +2142,9 @@ type layout struct { // main is the area for the main pane. (e.x chat, configure, landing) main uv.Rectangle + // pills is the area for the pills panel. + pills uv.Rectangle + // editor is the area for the editor pane. editor uv.Rectangle @@ -2208,6 +2318,19 @@ func isWhitespace(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' } +// isAgentBusy returns true if the agent coordinator exists and is currently +// busy processing a request. +func (m *UI) isAgentBusy() bool { + return m.com.App != nil && + m.com.App.AgentCoordinator != nil && + m.com.App.AgentCoordinator.IsBusy() +} + +// hasSession returns true if there is an active session with a valid ID. +func (m *UI) hasSession() bool { + return m.session != nil && m.session.ID != "" +} + // mimeOf detects the MIME type of the given content. func mimeOf(content []byte) string { mimeBufferSize := min(512, len(content)) @@ -2271,7 +2394,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea. } var cmds []tea.Cmd - if m.session == nil || m.session.ID == "" { + if !m.hasSession() { newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session") if err != nil { return uiutil.ReportError(err) @@ -2319,7 +2442,7 @@ func cancelTimerCmd() tea.Cmd { // and starts a timer. The second press (before the timer expires) actually // cancels the agent. func (m *UI) cancelAgent() tea.Cmd { - if m.session == nil || m.session.ID == "" { + if !m.hasSession() { return nil } @@ -2332,6 +2455,9 @@ func (m *UI) cancelAgent() tea.Cmd { // Second escape press - actually cancel the agent. m.isCanceling = false coordinator.Cancel(m.session.ID) + // Stop the spinning todo indicator. + m.todoIsSpinning = false + m.renderPills() return nil } @@ -2521,7 +2647,7 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti // newSession clears the current session state and prepares for a new session. // The actual session creation happens when the user sends their first message. func (m *UI) newSession() { - if m.session == nil || m.session.ID == "" { + if !m.hasSession() { return } @@ -2532,6 +2658,9 @@ func (m *UI) newSession() { m.textarea.Focus() m.chat.Blur() m.chat.ClearMessages() + m.pillsExpanded = false + m.promptQueue = 0 + m.pillsView = "" } // handlePasteMsg handles a paste message. diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 42cf3c8dbd44f8983f588bc303ef7ae142e71a70..21a2febea006c5366ba8bd7a30a17cfc1e4d0b0e 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -21,7 +21,7 @@ const ( WarningIcon string = "⚠" InfoIcon string = "ⓘ" HintIcon string = "∵" - SpinnerIcon string = "..." + SpinnerIcon string = "⋯" LoadingIcon string = "⟳" ModelIcon string = "◇" @@ -394,6 +394,18 @@ type Styles struct { Text lipgloss.Style Deleting lipgloss.Style } + + // Pills styles for todo/queue pills + Pills struct { + Base lipgloss.Style // Base pill style with padding + Focused lipgloss.Style // Focused pill with visible border + Blurred lipgloss.Style // Blurred pill with hidden border + QueueItemPrefix lipgloss.Style // Prefix for queue list items + HelpKey lipgloss.Style // Keystroke hint style + HelpText lipgloss.Style // Help action text style + Area lipgloss.Style // Pills area container + TodoSpinner lipgloss.Style // Todo spinner style + } } // ChromaTheme converts the current markdown chroma styles to a chroma @@ -1270,6 +1282,16 @@ func DefaultStyles() Styles { s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(fgMuted).Foreground(fgBase) s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(red).Foreground(fgBase) + // Pills styles + s.Pills.Base = base.Padding(0, 1) + s.Pills.Focused = base.Padding(0, 1).BorderStyle(lipgloss.RoundedBorder()).BorderForeground(bgOverlay) + s.Pills.Blurred = base.Padding(0, 1).BorderStyle(lipgloss.HiddenBorder()) + s.Pills.QueueItemPrefix = s.Muted.SetString(" •") + s.Pills.HelpKey = s.Muted + s.Pills.HelpText = s.Subtle + s.Pills.Area = base + s.Pills.TodoSpinner = base.Foreground(greenDark) + return s } From 292688313b69dd295d57618c73e104d9899ad6fd Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 20 Jan 2026 13:38:06 -0300 Subject: [PATCH 167/335] docs(readme): remove link to site that is offline (#1926) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6a57c7934d0714cd4e0ae3f30fab108d03196b98..8caf83d4562c4afbd72f87487548792b337da419 100644 --- a/README.md +++ b/README.md @@ -735,8 +735,8 @@ Or by setting the following in your config: } ``` -Crush also respects the [`DO_NOT_TRACK`](https://consoledonottrack.com) -convention which can be enabled via `export DO_NOT_TRACK=1`. +Crush also respects the `DO_NOT_TRACK` convention which can be enabled via +`export DO_NOT_TRACK=1`. ## Contributing From 077706036c3a6dc502d7f5d30f2bc77ad1b4d733 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 20 Jan 2026 15:21:10 -0300 Subject: [PATCH 168/335] fix: recent models dont go into the schema (#1892) Signed-off-by: Carlos Alexandro Becker --- internal/config/config.go | 3 ++- schema.json | 10 ---------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 2c414e3e9e35d6f232e00762f50aca1066aca321..dcff778d7cdc79494dfcbd54071fd3f2dff7cfc8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -359,8 +359,9 @@ type Config struct { // We currently only support large/small as values here. Models map[SelectedModelType]SelectedModel `json:"models,omitempty" jsonschema:"description=Model configurations for different model types,example={\"large\":{\"model\":\"gpt-4o\",\"provider\":\"openai\"}}"` + // Recently used models stored in the data directory config. - RecentModels map[SelectedModelType][]SelectedModel `json:"recent_models,omitempty" jsonschema:"description=Recently used models sorted by most recent first"` + RecentModels map[SelectedModelType][]SelectedModel `json:"recent_models,omitempty" jsonschema:"-"` // The providers that are configured Providers *csync.Map[string, ProviderConfig] `json:"providers,omitempty" jsonschema:"description=AI provider configurations"` diff --git a/schema.json b/schema.json index d92c398cc84704e69975f501c5f96e26ebfa7d1e..6eeaa40c1865ebb5e46f70964f4eba69cf47013e 100644 --- a/schema.json +++ b/schema.json @@ -63,16 +63,6 @@ "type": "object", "description": "Model configurations for different model types" }, - "recent_models": { - "additionalProperties": { - "items": { - "$ref": "#/$defs/SelectedModel" - }, - "type": "array" - }, - "type": "object", - "description": "Recently used models sorted by most recent first" - }, "providers": { "additionalProperties": { "$ref": "#/$defs/ProviderConfig" From 742b4d3543ed4977e2e9c578bac36cc6bcad509e Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 20 Jan 2026 18:36:59 -0500 Subject: [PATCH 169/335] feat(ui): use the new UI behind a feature flag If the environment variable CRUSH_NEW_UI is set, use the new UI implementation. --- internal/cmd/root.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 7f31db31c17fe66783f367a60009657ee8c11e50..c46aaaaf11519d31cc92a63b0d9ccc9b8313f494 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -88,11 +88,20 @@ crush -y // Set up the TUI. var env uv.Environ = os.Environ() - com := common.DefaultCommon(app) - ui := ui.New(com) - ui.QueryVersion = shouldQueryTerminalVersion(env) + + var model tea.Model + if _, ok := env.LookupEnv("CRUSH_NEW_UI"); ok { + com := common.DefaultCommon(app) + ui := ui.New(com) + ui.QueryVersion = shouldQueryTerminalVersion(env) + model = ui + } else { + ui := tui.New(app) + ui.QueryVersion = shouldQueryTerminalVersion(env) + model = ui + } program := tea.NewProgram( - ui, + model, tea.WithEnvironment(env), tea.WithContext(cmd.Context()), tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state From ead5bf4857f22e583b78c6b26a4caeea38594460 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 21 Jan 2026 15:07:58 +0100 Subject: [PATCH 170/335] refactor: mcp tool item (#1923) --- internal/ui/chat/mcp.go | 121 ++++++++++++++++++++++++++ internal/ui/chat/tools.go | 20 +++-- internal/ui/dialog/permissions.go | 135 ++++++++++++++++++++++-------- internal/ui/styles/styles.go | 10 +++ 4 files changed, 243 insertions(+), 43 deletions(-) create mode 100644 internal/ui/chat/mcp.go diff --git a/internal/ui/chat/mcp.go b/internal/ui/chat/mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..c4d124e7381a9ddaa39f56750367d3f2cf4d207f --- /dev/null +++ b/internal/ui/chat/mcp.go @@ -0,0 +1,121 @@ +package chat + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/stringext" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// MCPToolMessageItem is a message item that represents a bash tool call. +type MCPToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*MCPToolMessageItem)(nil) + +// NewMCPToolMessageItem creates a new [MCPToolMessageItem]. +func NewMCPToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &MCPToolRenderContext{}, canceled) +} + +// MCPToolRenderContext renders bash tool messages. +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) + } + mcpName := prettyName(toolNameParts[1]) + toolName := prettyName(toolNameParts[2]) + + mcpName = sty.Tool.MCPName.Render(mcpName) + toolName = sty.Tool.MCPToolName.Render(toolName) + + name := fmt.Sprintf("%s %s %s", mcpName, sty.Tool.MCPArrow.String(), toolName) + + if opts.IsPending() { + return pendingTool(sty, name, opts.Anim) + } + + var params map[string]any + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + var toolParams []string + if len(params) > 0 { + parsed, _ := json.Marshal(params) + toolParams = append(toolParams, string(parsed)) + } + + header := toolHeader(sty, opts.Status, name, cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if !opts.HasResult() || opts.Result.Content == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + // see if the result is json + var result json.RawMessage + var body string + if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil { + prettyResult, err := json.MarshalIndent(result, "", " ") + if err == nil { + body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent)) + } else { + body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + } + } else if looksLikeMarkdown(opts.Result.Content) { + body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent)) + } else { + body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + } + return joinToolParts(header, body) +} + +func prettyName(name string) string { + name = strings.ReplaceAll(name, "_", " ") + name = strings.ReplaceAll(name, "-", " ") + return stringext.Capitalize(name) +} + +// looksLikeMarkdown checks if content appears to be markdown by looking for +// common markdown patterns. +func looksLikeMarkdown(content string) bool { + patterns := []string{ + "# ", // headers + "## ", // headers + "**", // bold + "```", // code fence + "- ", // unordered list + "1. ", // ordered list + "> ", // blockquote + "---", // horizontal rule + "***", // horizontal rule + } + for _, p := range patterns { + if strings.Contains(content, p) { + return true + } + } + return false +} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 5c12279e50af551d8b1686afefb3cc52feda4c6d..b264dcea6b27a7bb09fac3b498d79b679373e6a6 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -243,14 +243,18 @@ func NewToolMessageItem( case tools.TodosToolName: item = NewTodosToolMessageItem(sty, toolCall, result, canceled) default: - // TODO: Implement other tool items - item = newBaseToolMessageItem( - sty, - toolCall, - result, - &DefaultToolRenderContext{}, - canceled, - ) + if strings.HasPrefix(toolCall.Name, "mcp_") { + item = NewMCPToolMessageItem(sty, toolCall, result, canceled) + } else { + // TODO: Implement other tool items + item = newBaseToolMessageItem( + sty, + toolCall, + result, + &DefaultToolRenderContext{}, + canceled, + ) + } } item.SetMessageID(messageID) return item diff --git a/internal/ui/dialog/permissions.go b/internal/ui/dialog/permissions.go index 87d592807f578d452c7a8f3a28931847426b8f62..8f2ca1ed27e7eff5096bcb33c8f516a07fe2dd88 100644 --- a/internal/ui/dialog/permissions.go +++ b/internal/ui/dialog/permissions.go @@ -13,7 +13,9 @@ import ( "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/stringext" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" uv "github.com/charmbracelet/ultraviolet" ) @@ -314,19 +316,19 @@ func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight // Calculate dialog dimensions based on fullscreen state and content type. - var width, height int + var width, maxHeight int if forceFullscreen || (p.fullscreen && p.hasDiffView()) { // Use nearly full window for fullscreen. width = area.Dx() - height = area.Dy() + maxHeight = area.Dy() } else if p.hasDiffView() { // Wide for side-by-side diffs, capped for readability. width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth) - height = int(float64(area.Dy()) * diffSizeRatio) + maxHeight = int(float64(area.Dy()) * diffSizeRatio) } else { // Narrower for simple content like commands/URLs. width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth) - height = int(float64(area.Dy()) * simpleHeightRatio) + maxHeight = int(float64(area.Dy()) * simpleHeightRatio) } dialogStyle := t.Dialog.View.Width(width).Padding(0, 1) @@ -341,27 +343,51 @@ func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { buttonsHeight := lipgloss.Height(buttons) helpHeight := lipgloss.Height(helpView) frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines - availableHeight := height - headerHeight - buttonsHeight - helpHeight - frameHeight p.defaultDiffSplitMode = width >= splitModeMinWidth - if p.viewport.Width() != contentWidth-1 { - // Mark diff content as dirty if width has changed + // Pre-render content to measure its actual height. + renderedContent := p.renderContent(contentWidth) + contentHeight := lipgloss.Height(renderedContent) + + // For non-diff views, shrink dialog to fit content if it's smaller than max. + var availableHeight int + if !p.hasDiffView() && !forceFullscreen { + fixedHeight := headerHeight + buttonsHeight + helpHeight + frameHeight + neededHeight := fixedHeight + contentHeight + if neededHeight < maxHeight { + availableHeight = contentHeight + } else { + availableHeight = maxHeight - fixedHeight + } + } else { + availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight + } + + // Determine if scrollbar is needed. + needsScrollbar := p.hasDiffView() || contentHeight > availableHeight + viewportWidth := contentWidth + if needsScrollbar { + viewportWidth = contentWidth - 1 // Reserve space for scrollbar. + } + + if p.viewport.Width() != viewportWidth { + // Mark content as dirty if width has changed. p.viewportDirty = true + renderedContent = p.renderContent(viewportWidth) } var content string var scrollbar string - // Non-diff content uses the viewport for scrolling. - p.viewport.SetWidth(contentWidth - 1) // -1 for scrollbar + p.viewport.SetWidth(viewportWidth) p.viewport.SetHeight(availableHeight) if p.viewportDirty { - p.viewport.SetContent(p.renderContent(contentWidth - 1)) + p.viewport.SetContent(renderedContent) p.viewportWidth = p.viewport.Width() p.viewportDirty = false } content = p.viewport.View() - if p.canScroll() { + if needsScrollbar { scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset()) } @@ -388,7 +414,7 @@ func (p *Permissions) renderHeader(contentWidth int) string { title = t.Dialog.Title.Render(title) // Tool info. - toolLine := p.renderKeyValue("Tool", p.permission.ToolName, contentWidth) + toolLine := p.renderToolName(contentWidth) pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth) lines := []string{title, "", toolLine, pathLine} @@ -439,10 +465,33 @@ func (p *Permissions) renderKeyValue(key, value string, width int) string { return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr) } +func (p *Permissions) renderToolName(width int) string { + toolName := p.permission.ToolName + + // Check if this is an MCP tool (format: mcp__). + if strings.HasPrefix(toolName, "mcp_") { + parts := strings.SplitN(toolName, "_", 3) + if len(parts) == 3 { + mcpName := prettyName(parts[1]) + toolPart := prettyName(parts[2]) + toolName = fmt.Sprintf("%s %s %s", mcpName, styles.ArrowRightIcon, toolPart) + } + } + + return p.renderKeyValue("Tool", toolName, width) +} + +// prettyName converts snake_case or kebab-case to Title Case. +func prettyName(name string) string { + name = strings.ReplaceAll(name, "_", " ") + name = strings.ReplaceAll(name, "-", " ") + return stringext.Capitalize(name) +} + func (p *Permissions) renderContent(width int) string { switch p.permission.ToolName { case tools.BashToolName: - return p.renderBashContent() + return p.renderBashContent(width) case tools.EditToolName: return p.renderEditContent(width) case tools.WriteToolName: @@ -450,27 +499,27 @@ func (p *Permissions) renderContent(width int) string { case tools.MultiEditToolName: return p.renderMultiEditContent(width) case tools.DownloadToolName: - return p.renderDownloadContent() + return p.renderDownloadContent(width) case tools.FetchToolName: - return p.renderFetchContent() + return p.renderFetchContent(width) case tools.AgenticFetchToolName: - return p.renderAgenticFetchContent() + return p.renderAgenticFetchContent(width) case tools.ViewToolName: - return p.renderViewContent() + return p.renderViewContent(width) case tools.LSToolName: - return p.renderLSContent() + return p.renderLSContent(width) default: - return p.renderDefaultContent() + return p.renderDefaultContent(width) } } -func (p *Permissions) renderBashContent() string { +func (p *Permissions) renderBashContent(width int) string { params, ok := p.permission.Params.(tools.BashPermissionsParams) if !ok { return "" } - return p.com.Styles.Dialog.ContentPanel.Render(params.Command) + return p.renderContentPanel(params.Command, width) } func (p *Permissions) renderEditContent(contentWidth int) string { @@ -529,7 +578,7 @@ func (p *Permissions) renderDiff(filePath, oldContent, newContent string, conten return result } -func (p *Permissions) renderDownloadContent() string { +func (p *Permissions) renderDownloadContent(width int) string { params, ok := p.permission.Params.(tools.DownloadPermissionsParams) if !ok { return "" @@ -540,19 +589,19 @@ func (p *Permissions) renderDownloadContent() string { content += fmt.Sprintf("\nTimeout: %ds", params.Timeout) } - return p.com.Styles.Dialog.ContentPanel.Render(content) + return p.renderContentPanel(content, width) } -func (p *Permissions) renderFetchContent() string { +func (p *Permissions) renderFetchContent(width int) string { params, ok := p.permission.Params.(tools.FetchPermissionsParams) if !ok { return "" } - return p.com.Styles.Dialog.ContentPanel.Render(params.URL) + return p.renderContentPanel(params.URL, width) } -func (p *Permissions) renderAgenticFetchContent() string { +func (p *Permissions) renderAgenticFetchContent(width int) string { params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams) if !ok { return "" @@ -565,10 +614,10 @@ func (p *Permissions) renderAgenticFetchContent() string { content = fmt.Sprintf("Prompt: %s", params.Prompt) } - return p.com.Styles.Dialog.ContentPanel.Render(content) + return p.renderContentPanel(content, width) } -func (p *Permissions) renderViewContent() string { +func (p *Permissions) renderViewContent(width int) string { params, ok := p.permission.Params.(tools.ViewPermissionsParams) if !ok { return "" @@ -582,10 +631,10 @@ func (p *Permissions) renderViewContent() string { content += fmt.Sprintf("\nLines to read: %d", params.Limit) } - return p.com.Styles.Dialog.ContentPanel.Render(content) + return p.renderContentPanel(content, width) } -func (p *Permissions) renderLSContent() string { +func (p *Permissions) renderLSContent(width int) string { params, ok := p.permission.Params.(tools.LSPermissionsParams) if !ok { return "" @@ -596,11 +645,16 @@ func (p *Permissions) renderLSContent() string { content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", ")) } - return p.com.Styles.Dialog.ContentPanel.Render(content) + return p.renderContentPanel(content, width) } -func (p *Permissions) renderDefaultContent() string { - content := p.permission.Description +func (p *Permissions) renderDefaultContent(width int) string { + t := p.com.Styles + var content string + // do not add the description for mcp tools + if !strings.HasPrefix(p.permission.ToolName, "mcp_") { + content = p.permission.Description + } // Pretty-print JSON params if available. if p.permission.Params != nil { @@ -614,10 +668,15 @@ func (p *Permissions) renderDefaultContent() string { var parsed any if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil { if b, err := json.MarshalIndent(parsed, "", " "); err == nil { + jsonContent := string(b) + highlighted, err := common.SyntaxHighlight(t, jsonContent, "params.json", t.BgSubtle) + if err == nil { + jsonContent = highlighted + } if content != "" { content += "\n\n" } - content += string(b) + content += jsonContent } } else if paramStr != "" { if content != "" { @@ -631,7 +690,13 @@ func (p *Permissions) renderDefaultContent() string { return "" } - return p.com.Styles.Dialog.ContentPanel.Render(strings.TrimSpace(content)) + return p.renderContentPanel(strings.TrimSpace(content), width) +} + +// renderContentPanel renders content in a panel with the full width. +func (p *Permissions) renderContentPanel(content string, width int) string { + panelStyle := p.com.Styles.Dialog.ContentPanel + return panelStyle.Width(width).Render(content) } func (p *Permissions) renderButtons(contentWidth int) string { diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 21a2febea006c5366ba8bd7a30a17cfc1e4d0b0e..f40fd5113bf1495fa5d35e0b891e397e6a90b6ec 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -310,6 +310,11 @@ type Styles struct { TodoCompletedIcon lipgloss.Style // Completed todo icon TodoInProgressIcon lipgloss.Style // In-progress todo icon TodoPendingIcon lipgloss.Style // Pending todo icon + + // MCP tools + MCPName lipgloss.Style // The mcp name + MCPToolName lipgloss.Style // The mcp tool name + MCPArrow lipgloss.Style // The mcp arrow icon } // Dialog styles @@ -1130,6 +1135,11 @@ func DefaultStyles() Styles { s.Tool.TodoInProgressIcon = base.Foreground(greenDark) s.Tool.TodoPendingIcon = base.Foreground(fgMuted) + // MCP styles + s.Tool.MCPName = base.Foreground(blue) + s.Tool.MCPToolName = base.Foreground(blueDark) + s.Tool.MCPArrow = base.Foreground(blue).SetString(ArrowRightIcon) + // Buttons s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary) s.ButtonBlur = s.Base.Background(bgSubtle) From e2b071d0cea57829f85270871753af34535e1a25 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 21 Jan 2026 10:56:50 -0500 Subject: [PATCH 171/335] chore: add log when new UI is enabled --- internal/cmd/root.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c46aaaaf11519d31cc92a63b0d9ccc9b8313f494..db50f8138085ac825848aaf0b97d229d97f4f57c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -91,6 +91,7 @@ crush -y var model tea.Model if _, ok := env.LookupEnv("CRUSH_NEW_UI"); ok { + slog.Info("New UI in control!") com := common.DefaultCommon(app) ui := ui.New(com) ui.QueryVersion = shouldQueryTerminalVersion(env) From 7c21b1f0c06d414f9205beae9bd14834fb36b12f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 21 Jan 2026 13:32:30 -0500 Subject: [PATCH 172/335] fix: use strconv.ParseBool Co-authored-by: Andrey Nering --- internal/cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index db50f8138085ac825848aaf0b97d229d97f4f57c..4146244553b76ba4c0c2967636d7a077b706ee0d 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -90,7 +90,7 @@ crush -y var env uv.Environ = os.Environ() var model tea.Model - if _, ok := env.LookupEnv("CRUSH_NEW_UI"); ok { + if v, _ := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); v { slog.Info("New UI in control!") com := common.DefaultCommon(app) ui := ui.New(com) From b66676c52acc2ae0d151d4f5f819820fd188ddcd Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 21 Jan 2026 15:52:17 -0300 Subject: [PATCH 173/335] fix(sec): do not output resolved command (#1934) Signed-off-by: Carlos Alexandro Becker Co-authored-by: Andrey Nering --- internal/shell/shell.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/shell/shell.go b/internal/shell/shell.go index e5a54f01c403ae1b8de681616c5d693bc842ac14..ced8da26ed4e837b08e66152e9aafb2cc029c0d1 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -227,7 +227,7 @@ func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHand for _, blockFunc := range s.blockFuncs { if blockFunc(args) { - return fmt.Errorf("command is not allowed for security reasons: %s", strings.Join(args, " ")) + return fmt.Errorf("command is not allowed for security reasons: %q", args[0]) } } From 4086e2f007ac54f1785535546f0fc0bae47afada Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 21 Jan 2026 17:16:51 -0300 Subject: [PATCH 174/335] feat: increase paste lines as attachment threshold (#1936) Signed-off-by: Carlos Alexandro Becker --- internal/tui/components/chat/editor/editor.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 8f7c43c76a965539db3c3d6de4f46377c8a10a5c..ba832b415133305fccbefa37da6b749405feb2c6 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -39,6 +39,9 @@ var ( 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 @@ -239,8 +242,7 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { m.textarea.SetValue(msg.Text) m.textarea.MoveToEnd() case tea.PasteMsg: - // If pasted text has more than 2 newlines, treat it as a file attachment. - if strings.Count(msg.Content, "\n") > 2 { + if strings.Count(msg.Content, "\n") > pasteLinesThreshold { content := []byte(msg.Content) if len(content) > maxAttachmentSize { return m, util.ReportWarn("Paste is too big (>5mb)") From d5b83c0dbbaa228bf3f783495d73e6db84489e2d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 21 Jan 2026 11:43:30 -0500 Subject: [PATCH 175/335] fix(ui): implement Highlightable interface for message items --- internal/ui/chat/messages.go | 11 +++++-- internal/ui/list/highlight.go | 58 +++++++++++++++++++++++++++++------ internal/ui/list/item.go | 8 +++-- internal/ui/model/chat.go | 2 +- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 68954b0f3f0168b9da91b1b28db1b5101e5f9c3b..a666aa44607c8dece9ac0be80b32e5297091cb33 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -77,6 +77,8 @@ type highlightableMessageItem struct { highlighter list.Highlighter } +var _ list.Highlightable = (*highlightableMessageItem)(nil) + // isHighlighted returns true if the item has a highlight range set. func (h *highlightableMessageItem) isHighlighted() bool { return h.startLine != -1 || h.endLine != -1 @@ -91,8 +93,8 @@ func (h *highlightableMessageItem) renderHighlighted(content string, width, heig return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter) } -// Highlight implements MessageItem. -func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLine int, endCol int) { +// SetHighlight implements [MessageItem]. +func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) { // Adjust columns for the style's left inset (border + padding) since we // highlight the content only. offset := messageLeftPaddingTotal @@ -106,6 +108,11 @@ func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLin } } +// Highlight implements [MessageItem]. +func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, endLine int, endCol int) { + return h.startLine, h.startCol, h.endLine, h.endCol +} + func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem { return &highlightableMessageItem{ startLine: -1, diff --git a/internal/ui/list/highlight.go b/internal/ui/list/highlight.go index c61a53a18ffc2aced7f5ec21f31e2fe4f4916522..fefe836d110b52496028d21071fffc5262189d92 100644 --- a/internal/ui/list/highlight.go +++ b/internal/ui/list/highlight.go @@ -2,25 +2,60 @@ package list import ( "image" + "strings" "charm.land/lipgloss/v2" uv "github.com/charmbracelet/ultraviolet" ) // DefaultHighlighter is the default highlighter function that applies inverse style. -var DefaultHighlighter Highlighter = func(s uv.Style) uv.Style { - s.Attrs |= uv.AttrReverse - return s +var DefaultHighlighter Highlighter = func(x, y int, c *uv.Cell) *uv.Cell { + if c == nil { + return c + } + c.Style.Attrs |= uv.AttrReverse + return c } // Highlighter represents a function that defines how to highlight text. -type Highlighter func(uv.Style) uv.Style +type Highlighter func(x, y int, c *uv.Cell) *uv.Cell + +// HighlightContent returns the content with highlighted regions based on the specified parameters. +func HighlightContent(content string, area image.Rectangle, startLine, startCol, endLine, endCol int) string { + var sb strings.Builder + pos := image.Pt(-1, -1) + HighlightBuffer(content, area, startLine, startCol, endLine, endCol, func(x, y int, c *uv.Cell) *uv.Cell { + pos.X = x + if pos.Y == -1 { + pos.Y = y + } else if y > pos.Y { + sb.WriteString(strings.Repeat("\n", y-pos.Y)) + pos.Y = y + } + sb.WriteString(c.Content) + return c + }) + if sb.Len() > 0 { + sb.WriteString("\n") + } + return sb.String() +} // Highlight highlights a region of text within the given content and region. func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string { - if startLine < 0 || startCol < 0 { + buf := HighlightBuffer(content, area, startLine, startCol, endLine, endCol, highlighter) + if buf == nil { return content } + return buf.Render() +} + +// HighlightBuffer highlights a region of text within the given content and +// region, returning a [uv.ScreenBuffer]. +func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer { + if startLine < 0 || startCol < 0 { + return nil + } if highlighter == nil { highlighter = DefaultHighlighter @@ -87,17 +122,22 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin continue } cell := line.At(x) - cell.Style = highlighter(cell.Style) + if cell != nil { + line.Set(x, highlighter(x, y, cell)) + } } } - return buf.Render() + return &buf } // ToHighlighter converts a [lipgloss.Style] to a [Highlighter]. func ToHighlighter(lgStyle lipgloss.Style) Highlighter { - return func(uv.Style) uv.Style { - return ToStyle(lgStyle) + return func(_ int, _ int, c *uv.Cell) *uv.Cell { + if c != nil { + c.Style = ToStyle(lgStyle) + } + return c } } diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go index 62b31a696eee11b5dc11f0228d82ccfa8a0c91e5..dc6f9a345854672116a08f2fd988a15e53b160bd 100644 --- a/internal/ui/list/item.go +++ b/internal/ui/list/item.go @@ -21,9 +21,11 @@ type Focusable interface { // Highlightable represents an item that can highlight a portion of its content. type Highlightable interface { - // Highlight highlights the content from the given start to end positions. - // Use -1 for no highlight. - Highlight(startLine, startCol, endLine, endCol int) + // SetHighlight highlights the content from the given start to end + // positions. Use -1 for no highlight. + SetHighlight(startLine, startCol, endLine, endCol int) + // Highlight returns the current highlight positions within the item. + Highlight() (startLine, startCol, endLine, endCol int) } // MouseClickable represents an item that can handle mouse click events. diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index a6c8fb1cf213be37c8f095ba776f936bec96b57a..abe45997fb447a48bdbb6b2df2ef52ec3e1fb99a 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -515,7 +515,7 @@ func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.It } } - hi.Highlight(sLine, sCol, eLine, eCol) + hi.SetHighlight(sLine, sCol, eLine, eCol) return hi.(list.Item) } From dd9e8ca15c72160f63fb017777a76029d65cbcef Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 21 Jan 2026 11:48:42 -0500 Subject: [PATCH 176/335] fix(ui): list: move focused logic to render callback --- internal/ui/dialog/models_list.go | 1 + internal/ui/list/filterable.go | 1 + internal/ui/list/focus.go | 13 +++++++++++++ internal/ui/list/list.go | 20 +++++++++++++++----- 4 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 internal/ui/list/focus.go diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index bbd4dafad3591db2e62624243fd9ae919bed5206..c0eaba437154a78df3865ab9cd0e96c5c9c57321 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -26,6 +26,7 @@ func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList { groups: groups, t: sty, } + f.RegisterRenderCallback(list.FocusedRenderCallback(f.List)) return f } diff --git a/internal/ui/list/filterable.go b/internal/ui/list/filterable.go index d3c227f0234028aea22fcc397d861c263cab034a..ed018e2e1e26e7d5a5bbc091b17c572331811dc5 100644 --- a/internal/ui/list/filterable.go +++ b/internal/ui/list/filterable.go @@ -31,6 +31,7 @@ func NewFilterableList(items ...FilterableItem) *FilterableList { List: NewList(), items: items, } + f.RegisterRenderCallback(FocusedRenderCallback(f.List)) f.SetItems(items...) return f } diff --git a/internal/ui/list/focus.go b/internal/ui/list/focus.go new file mode 100644 index 0000000000000000000000000000000000000000..6bdee37afa39a69d6d321b1894c6a5f221fc307d --- /dev/null +++ b/internal/ui/list/focus.go @@ -0,0 +1,13 @@ +package list + +// FocusedRenderCallback is a helper function that returns a render callback +// that marks items as focused during rendering. +func FocusedRenderCallback(list *List) RenderCallback { + return func(idx, selectedIdx int, item Item) Item { + if focusable, ok := item.(Focusable); ok { + focusable.SetFocused(list.Focused() && idx == selectedIdx) + return focusable.(Item) + } + return item + } +} diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 8806551c537ecbfdcba8169bc05d7de79183b0ba..78cb437d361f8ec05d81acfa98f2e87a23755d58 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -49,9 +49,13 @@ func NewList(items ...Item) *List { return l } +// RenderCallback defines a function that can modify an item before it is +// rendered. +type RenderCallback func(idx, selectedIdx int, item Item) Item + // RegisterRenderCallback registers a callback to be called when rendering // items. This can be used to modify items before they are rendered. -func (l *List) RegisterRenderCallback(cb func(idx, selectedIdx int, item Item) Item) { +func (l *List) RegisterRenderCallback(cb RenderCallback) { l.renderCallbacks = append(l.renderCallbacks, cb) } @@ -66,6 +70,11 @@ func (l *List) SetGap(gap int) { l.gap = gap } +// Gap returns the gap between items. +func (l *List) Gap() int { + return l.gap +} + // SetReverse shows the list in reverse order. func (l *List) SetReverse(reverse bool) { l.reverse = reverse @@ -101,10 +110,6 @@ func (l *List) getItem(idx int) renderedItem { } } - if focusable, isFocusable := item.(Focusable); isFocusable { - focusable.SetFocused(l.focused && idx == l.selectedIdx) - } - rendered := item.Render(l.width) rendered = strings.TrimRight(rendered, "\n") height := countLines(rendered) @@ -348,6 +353,11 @@ func (l *List) RemoveItem(idx int) { } } +// Focused returns whether the list is focused. +func (l *List) Focused() bool { + return l.focused +} + // Focus sets the focus state of the list. func (l *List) Focus() { l.focused = true From a5c597bdc26d27025046daaa19b8eecb1d558fc6 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 21 Jan 2026 15:15:15 -0500 Subject: [PATCH 177/335] feat(ui): copy chat highlighted content to clipboard This commit adds functionality to the chat UI that allows users to copy highlighted text to the clipboard. --- internal/ui/chat/assistant.go | 21 +++++++++------ internal/ui/chat/messages.go | 18 +++++++------ internal/ui/chat/tools.go | 29 ++++++++++++--------- internal/ui/chat/user.go | 22 +++++++++------- internal/ui/list/item.go | 8 ++++++ internal/ui/model/chat.go | 48 ++++++++++++++++++++++++++++++++--- internal/ui/model/ui.go | 40 +++++++++++++++++++++++++++-- 7 files changed, 144 insertions(+), 42 deletions(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 91849673c3a9eea603c85c54a7c5ac77d759c2ae..7ff53264ead1b2e264cec981ef6aa5cb541247d3 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -77,13 +77,9 @@ func (a *AssistantMessageItem) ID() string { return a.message.ID } -// Render implements MessageItem. -func (a *AssistantMessageItem) Render(width int) string { +// RawRender implements [MessageItem]. +func (a *AssistantMessageItem) RawRender(width int) string { cappedWidth := cappedMessageWidth(width) - style := a.sty.Chat.Message.AssistantBlurred - if a.focused { - style = a.sty.Chat.Message.AssistantFocused - } var spinner string if a.isSpinning() { @@ -103,10 +99,19 @@ func (a *AssistantMessageItem) Render(width int) string { if highlightedContent != "" { highlightedContent += "\n\n" } - return style.Render(highlightedContent + spinner) + return highlightedContent + spinner } - return style.Render(highlightedContent) + return highlightedContent +} + +// Render implements MessageItem. +func (a *AssistantMessageItem) Render(width int) string { + style := a.sty.Chat.Message.AssistantBlurred + if a.focused { + style = a.sty.Chat.Message.AssistantFocused + } + return style.Render(a.RawRender(width)) } // renderMessageContent renders the message content including thinking, main content, and finish reason. diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index a666aa44607c8dece9ac0be80b32e5297091cb33..6be07e4759020c9aed25f042668d89e96584cccc 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -1,6 +1,3 @@ -// Package chat provides UI components for displaying and managing chat messages. -// It defines message item types that can be rendered in a list view, including -// support for highlighting, focusing, and caching rendered content. package chat import ( @@ -48,6 +45,7 @@ type Expandable interface { // UI and be part of a [list.List] identifiable by a unique ID. type MessageItem interface { list.Item + list.RawRenderable Identifiable } @@ -93,7 +91,7 @@ func (h *highlightableMessageItem) renderHighlighted(content string, width, heig return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter) } -// SetHighlight implements [MessageItem]. +// SetHighlight implements list.Highlightable. func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) { // Adjust columns for the style's left inset (border + padding) since we // highlight the content only. @@ -108,7 +106,7 @@ func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, end } } -// Highlight implements [MessageItem]. +// Highlight implements list.Highlightable. func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, endLine int, endCol int) { return h.startLine, h.startCol, h.endLine, h.endCol } @@ -200,8 +198,8 @@ func (a *AssistantInfoItem) ID() string { return a.id } -// Render implements MessageItem. -func (a *AssistantInfoItem) Render(width int) string { +// RawRender implements MessageItem. +func (a *AssistantInfoItem) RawRender(width int) string { innerWidth := max(0, width-messageLeftPaddingTotal) content, _, ok := a.getCachedRender(innerWidth) if !ok { @@ -209,8 +207,12 @@ func (a *AssistantInfoItem) Render(width int) string { height := lipgloss.Height(content) a.setCachedRender(content, innerWidth, height) } + return content +} - return a.sty.Chat.Message.SectionHeader.Render(content) +// Render implements MessageItem. +func (a *AssistantInfoItem) Render(width int) string { + return a.sty.Chat.Message.SectionHeader.Render(a.RawRender(width)) } func (a *AssistantInfoItem) renderContent(width int) string { diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index b264dcea6b27a7bb09fac3b498d79b679373e6a6..e703cb1c096c0fa889438a446a8f042b892d9e31 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -287,20 +287,12 @@ func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { return t.anim.Animate(msg) } -// Render renders the tool message item at the given width. -func (t *baseToolMessageItem) Render(width int) string { +// RawRender implements [MessageItem]. +func (t *baseToolMessageItem) RawRender(width int) string { toolItemWidth := width - messageLeftPaddingTotal if t.hasCappedWidth { toolItemWidth = cappedMessageWidth(width) } - style := t.sty.Chat.Message.ToolCallBlurred - if t.focused { - style = t.sty.Chat.Message.ToolCallFocused - } - - if t.isCompact { - style = t.sty.Chat.Message.ToolCallCompact - } content, height, ok := t.getCachedRender(toolItemWidth) // if we are spinning or there is no cache rerender @@ -319,8 +311,21 @@ func (t *baseToolMessageItem) Render(width int) string { t.setCachedRender(content, toolItemWidth, height) } - highlightedContent := t.renderHighlighted(content, toolItemWidth, height) - return style.Render(highlightedContent) + return t.renderHighlighted(content, toolItemWidth, height) +} + +// Render renders the tool message item at the given width. +func (t *baseToolMessageItem) Render(width int) string { + style := t.sty.Chat.Message.ToolCallBlurred + if t.focused { + style = t.sty.Chat.Message.ToolCallFocused + } + + if t.isCompact { + style = t.sty.Chat.Message.ToolCallCompact + } + + return style.Render(t.RawRender(width)) } // ToolCall returns the tool call associated with this message item. diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go index 5eb452b1fbc396f3c603af89dea9de000502fb94..7383c841ae3e274bdfea8dcc4db37e1259dbbb21 100644 --- a/internal/ui/chat/user.go +++ b/internal/ui/chat/user.go @@ -33,19 +33,14 @@ func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachment } } -// Render implements MessageItem. -func (m *UserMessageItem) Render(width int) string { +// RawRender implements [MessageItem]. +func (m *UserMessageItem) RawRender(width int) string { cappedWidth := cappedMessageWidth(width) - style := m.sty.Chat.Message.UserBlurred - if m.focused { - style = m.sty.Chat.Message.UserFocused - } - content, height, ok := m.getCachedRender(cappedWidth) // cache hit if ok { - return style.Render(m.renderHighlighted(content, cappedWidth, height)) + return m.renderHighlighted(content, cappedWidth, height) } renderer := common.MarkdownRenderer(m.sty, cappedWidth) @@ -69,7 +64,16 @@ func (m *UserMessageItem) Render(width int) string { height = lipgloss.Height(content) m.setCachedRender(content, cappedWidth, height) - return style.Render(m.renderHighlighted(content, cappedWidth, height)) + return m.renderHighlighted(content, cappedWidth, height) +} + +// Render implements MessageItem. +func (m *UserMessageItem) Render(width int) string { + style := m.sty.Chat.Message.UserBlurred + if m.focused { + style = m.sty.Chat.Message.UserFocused + } + return style.Render(m.RawRender(width)) } // ID implements MessageItem. diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go index dc6f9a345854672116a08f2fd988a15e53b160bd..7ac87212889dbc58773b409b5a4a96ec47d1fede 100644 --- a/internal/ui/list/item.go +++ b/internal/ui/list/item.go @@ -13,6 +13,14 @@ type Item interface { Render(width int) string } +// RawRenderable represents an item that can provide a raw rendering +// without additional styling. +type RawRenderable interface { + // RawRender returns the raw rendered string without any additional + // styling. + RawRender(width int) string +} + // Focusable represents an item that can be aware of focus state changes. type Focusable interface { // SetFocused sets the focus state of the item. diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index abe45997fb447a48bdbb6b2df2ef52ec3e1fb99a..9ab77c60f6b1a48228c5d08ae4d9827584b62e6d 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -1,7 +1,10 @@ package model import ( + "strings" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/common" @@ -43,6 +46,7 @@ func NewChat(com *common.Common) *Chat { l := list.NewList() l.SetGap(1) l.RegisterRenderCallback(c.applyHighlightRange) + l.RegisterRenderCallback(list.FocusedRenderCallback(l)) c.list = l c.mouseDownItem = -1 c.mouseDragItem = -1 @@ -445,9 +449,6 @@ func (m *Chat) HandleMouseUp(x, y int) bool { return false } - // TODO: Handle the behavior when mouse is released after a drag selection - // (e.g., copy selected text to clipboard) - m.mouseDown = false return true } @@ -474,6 +475,47 @@ func (m *Chat) HandleMouseDrag(x, y int) bool { return true } +// HasHighlight returns whether there is currently highlighted content. +func (m *Chat) HasHighlight() bool { + startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange() + return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol) +} + +// HighlighContent returns the currently highlighted content based on the mouse +// selection. It returns an empty string if no content is highlighted. +func (m *Chat) HighlighContent() string { + startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange() + if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol { + return "" + } + + var sb strings.Builder + for i := startItemIdx; i <= endItemIdx; i++ { + item := m.list.ItemAt(i) + if hi, ok := item.(list.Highlightable); ok { + startLine, startCol, endLine, endCol := hi.Highlight() + listWidth := m.list.Width() + var rendered string + if rr, ok := item.(list.RawRenderable); ok { + rendered = rr.RawRender(listWidth) + } else { + rendered = item.Render(listWidth) + } + sb.WriteString(list.HighlightContent( + rendered, + uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)), + startLine, + startCol, + endLine, + endCol, + )) + sb.WriteString(strings.Repeat("\n", m.list.Gap())) + } + } + + return strings.TrimSpace(sb.String()) +} + // ClearMouse clears the current mouse interaction state. func (m *Chat) ClearMouse() { m.mouseDown = false diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 5b33117771717e5c4b84d31fabe7d2f238ca4b95..fe06906523ab032a9952a0f8c15e8c2a972faac3 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -23,6 +23,7 @@ import ( "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/atotto/clipboard" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/app" @@ -103,6 +104,9 @@ type ( // closeDialogMsg is sent to close the current dialog. closeDialogMsg struct{} + + // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard. + copyChatHighlightMsg struct{} ) // UI represents the main user interface model. @@ -200,6 +204,9 @@ type UI struct { // Todo spinner todoSpinner spinner.Model todoIsSpinning bool + + // mouse highlighting related state + lastClickTime time.Time } // New creates a new instance of the [UI] model. @@ -481,6 +488,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.keyMap.Models.SetHelp("ctrl+m", "models") m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline") } + case copyChatHighlightMsg: + cmds = append(cmds, m.copyChatHighlight()) case tea.MouseClickMsg: switch m.state { case uiChat: @@ -488,7 +497,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Adjust for chat area position x -= m.layout.main.Min.X y -= m.layout.main.Min.Y - m.chat.HandleMouseDown(x, y) + if m.chat.HandleMouseDown(x, y) { + m.lastClickTime = time.Now() + } } case tea.MouseMotionMsg: @@ -524,13 +535,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.MouseReleaseMsg: + const doubleClickThreshold = 500 * time.Millisecond + switch m.state { case uiChat: x, y := msg.X, msg.Y // Adjust for chat area position x -= m.layout.main.Min.X y -= m.layout.main.Min.Y - m.chat.HandleMouseUp(x, y) + if m.chat.HandleMouseUp(x, y) { + cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg { + if time.Since(m.lastClickTime) >= doubleClickThreshold { + return copyChatHighlightMsg{} + } + return nil + })) + } } case tea.MouseWheelMsg: // Pass mouse events to dialogs first if any are open. @@ -2842,6 +2862,22 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string return tea.Sequence(cmds...) } +func (m *UI) copyChatHighlight() tea.Cmd { + text := m.chat.HighlighContent() + return tea.Sequence( + tea.SetClipboard(text), + func() tea.Msg { + _ = clipboard.WriteAll(text) + return nil + }, + func() tea.Msg { + m.chat.ClearMouse() + return nil + }, + uiutil.ReportInfo("Selected text copied to clipboard"), + ) +} + // 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{ From 158f7a957cb8438324335c97783805b28ee3b18c Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 21 Jan 2026 17:18:21 -0300 Subject: [PATCH 178/335] fix(ui): increase paste lines threshold (#1937) Signed-off-by: Carlos Alexandro Becker --- internal/ui/model/ui.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 5b33117771717e5c4b84d31fabe7d2f238ca4b95..e26b9c437655b06a87f7998125940a8f6ad04443 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -56,6 +56,9 @@ const ( compactModeHeightBreakpoint = 30 ) +// If pasted text has more than 2 newlines, treat it as a file attachment. +const pasteLinesThreshold = 10 + // Session details panel max height. const sessionDetailsMaxHeight = 20 @@ -2680,8 +2683,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { return nil } - // If pasted text has more than 2 newlines, treat it as a file attachment. - if strings.Count(msg.Content, "\n") > 2 { + if strings.Count(msg.Content, "\n") > pasteLinesThreshold { return func() tea.Msg { content := []byte(msg.Content) if int64(len(content)) > common.MaxAttachmentSize { From 366942352a14d6361e40ef44a470bb051548fa78 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 21 Jan 2026 17:41:38 -0300 Subject: [PATCH 179/335] feat: crush run --model, and crush models (#1889) Signed-off-by: Carlos Alexandro Becker --- go.mod | 3 +- go.sum | 2 + internal/app/app.go | 98 +++++++++++++++- internal/app/provider.go | 95 +++++++++++++++ internal/app/provider_test.go | 210 ++++++++++++++++++++++++++++++++++ internal/cmd/models.go | 110 ++++++++++++++++++ internal/cmd/run.go | 6 +- 7 files changed, 521 insertions(+), 3 deletions(-) create mode 100644 internal/app/provider.go create mode 100644 internal/app/provider_test.go create mode 100644 internal/cmd/models.go diff --git a/go.mod b/go.mod index d86aeba68814867bac69e6a113d0887d75405003..e7068c241fbc3d5c78c166445b77f240d437ea2b 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( 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/strings v0.0.0-20260119114936-fd556377ea59 github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b github.com/charmbracelet/x/term v0.2.2 github.com/denisbrodbeck/machineid v1.0.1 @@ -38,6 +39,7 @@ require ( github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 github.com/lucasb-eyer/go-colorful v1.3.0 + github.com/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.4 @@ -135,7 +137,6 @@ require ( github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect diff --git a/go.sum b/go.sum index 09a239110a64328bd17452d2e4f8bbabe2a75770..8bb193020a32665c5189fa7c16acb9fa4995bb0e 100644 --- a/go.sum +++ b/go.sum @@ -118,6 +118,8 @@ github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sB 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/strings v0.0.0-20260119114936-fd556377ea59 h1:cvPMInXNmK/CHjQU8eXC/oSnGfEKpQmndsEykh03bt0= +github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ= github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM= github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8= diff --git a/internal/app/app.go b/internal/app/app.go index 96750a453e63c79b7363fef5b3aa9b09632de940..816f7a8b25cc945436a148110ce7d774750bb493 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,6 +17,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/fantasy" "charm.land/lipgloss/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/config" @@ -131,12 +132,18 @@ func (app *App) Config() *config.Config { // RunNonInteractive runs the application in non-interactive mode with the // given prompt, printing to stdout. -func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt string, quiet bool) error { +func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, quiet bool) error { slog.Info("Running in non-interactive mode") ctx, cancel := context.WithCancel(ctx) defer cancel() + if largeModel != "" || smallModel != "" { + if err := app.overrideModelsForNonInteractive(ctx, largeModel, smallModel); err != nil { + return fmt.Errorf("failed to override models: %w", err) + } + } + var ( spinner *format.Spinner stdoutTTY bool @@ -299,6 +306,95 @@ func (app *App) UpdateAgentModel(ctx context.Context) error { return app.AgentCoordinator.UpdateModels(ctx) } +// overrideModelsForNonInteractive parses the model strings and temporarily +// overrides the model configurations, then rebuilds the agent. +// Format: "model-name" (searches all providers) or "provider/model-name". +// Model matching is case-insensitive. +// If largeModel is provided but smallModel is not, the small model defaults to +// the provider's default small model. +func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, smallModel string) error { + providers := app.config.Providers.Copy() + + largeMatches, smallMatches, err := findModels(providers, largeModel, smallModel) + if err != nil { + return err + } + + var largeProviderID string + + // Override large model. + if largeModel != "" { + found, err := validateMatches(largeMatches, largeModel, "large") + if err != nil { + return err + } + largeProviderID = found.provider + slog.Info("Overriding large model for non-interactive run", "provider", found.provider, "model", found.modelID) + app.config.Models[config.SelectedModelTypeLarge] = config.SelectedModel{ + Provider: found.provider, + Model: found.modelID, + } + } + + // Override small model. + switch { + case smallModel != "": + found, err := validateMatches(smallMatches, smallModel, "small") + if err != nil { + return err + } + slog.Info("Overriding small model for non-interactive run", "provider", found.provider, "model", found.modelID) + app.config.Models[config.SelectedModelTypeSmall] = config.SelectedModel{ + Provider: found.provider, + Model: found.modelID, + } + + case largeModel != "": + // No small model specified, but large model was - use provider's default. + smallCfg := app.getDefaultSmallModel(largeProviderID) + app.config.Models[config.SelectedModelTypeSmall] = smallCfg + } + + return app.AgentCoordinator.UpdateModels(ctx) +} + +// provider. Falls back to the large model if no default is found. +func (app *App) getDefaultSmallModel(providerID string) config.SelectedModel { + cfg := app.config + largeModelCfg := cfg.Models[config.SelectedModelTypeLarge] + + // Find the provider in the known providers list to get its default small model. + knownProviders, _ := config.Providers(cfg) + var knownProvider *catwalk.Provider + for _, p := range knownProviders { + if string(p.ID) == providerID { + knownProvider = &p + break + } + } + + // For unknown/local providers, use the large model as small. + if knownProvider == nil { + slog.Warn("Using large model as small model for unknown provider", "provider", providerID, "model", largeModelCfg.Model) + return largeModelCfg + } + + defaultSmallModelID := knownProvider.DefaultSmallModelID + model := cfg.GetModel(providerID, defaultSmallModelID) + if model == nil { + slog.Warn("Default small model not found, using large model", "provider", providerID, "model", largeModelCfg.Model) + return largeModelCfg + } + + slog.Info("Using provider default small model", "provider", providerID, "model", defaultSmallModelID) + return config.SelectedModel{ + Provider: providerID, + Model: defaultSmallModelID, + MaxTokens: model.DefaultMaxTokens, + ReasoningEffort: model.DefaultReasoningEffort, + } +} + func (app *App) setupEvents() { ctx, cancel := context.WithCancel(app.globalCtx) app.eventsCtx = ctx diff --git a/internal/app/provider.go b/internal/app/provider.go new file mode 100644 index 0000000000000000000000000000000000000000..570edadf9e1647eeeeab32107d3da3a1d3494935 --- /dev/null +++ b/internal/app/provider.go @@ -0,0 +1,95 @@ +package app + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/crush/internal/config" + xstrings "github.com/charmbracelet/x/exp/strings" +) + +// parseModelStr parses a model string into provider filter and model ID. +// Format: "model-name" or "provider/model-name" or "synthetic/moonshot/kimi-k2". +// This function only checks if the first component is a valid provider name; if not, +// it treats the entire string as a model ID (which may contain slashes). +func parseModelStr(providers map[string]config.ProviderConfig, modelStr string) (providerFilter, modelID string) { + parts := strings.Split(modelStr, "/") + if len(parts) == 1 { + return "", parts[0] + } + // Check if the first part is a valid provider name + if _, ok := providers[parts[0]]; ok { + return parts[0], strings.Join(parts[1:], "/") + } + + // First part is not a valid provider, treat entire string as model ID + return "", modelStr +} + +// modelMatch represents a found model. +type modelMatch struct { + provider string + modelID string +} + +func findModels(providers map[string]config.ProviderConfig, largeModel, smallModel string) ([]modelMatch, []modelMatch, error) { + largeProviderFilter, largeModelID := parseModelStr(providers, largeModel) + smallProviderFilter, smallModelID := parseModelStr(providers, smallModel) + + // Validate provider filters exist. + for _, pf := range []struct { + filter, label string + }{ + {largeProviderFilter, "large"}, + {smallProviderFilter, "small"}, + } { + if pf.filter != "" { + if _, ok := providers[pf.filter]; !ok { + return nil, nil, fmt.Errorf("%s model: provider %q not found in configuration. Use 'crush models' to list available models", pf.label, pf.filter) + } + } + } + + // Find matching models in a single pass. + var largeMatches, smallMatches []modelMatch + for name, provider := range providers { + if provider.Disable { + continue + } + for _, m := range provider.Models { + if filter(largeModelID, largeProviderFilter, m.ID, name) { + largeMatches = append(largeMatches, modelMatch{provider: name, modelID: m.ID}) + } + if filter(smallModelID, smallProviderFilter, m.ID, name) { + smallMatches = append(smallMatches, modelMatch{provider: name, modelID: m.ID}) + } + } + } + + return largeMatches, smallMatches, nil +} + +func filter(modelFilter, providerFilter, model, provider string) bool { + return modelFilter != "" && model == modelFilter && + (providerFilter == "" || provider == providerFilter) +} + +// Validate and return a single match. +func validateMatches(matches []modelMatch, modelID, label string) (modelMatch, error) { + switch { + case len(matches) == 0: + return modelMatch{}, fmt.Errorf("%s model %q not found", label, modelID) + case len(matches) > 1: + names := make([]string, len(matches)) + for i, m := range matches { + names[i] = m.provider + } + return modelMatch{}, fmt.Errorf( + "%s model: model %q found in multiple providers: %s. Please specify provider using 'provider/model' format", + label, + modelID, + xstrings.EnglishJoin(names, true), + ) + } + return matches[0], nil +} diff --git a/internal/app/provider_test.go b/internal/app/provider_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c3acae64d1057f3bb8bd8f9a0cb6443dbe9731b7 --- /dev/null +++ b/internal/app/provider_test.go @@ -0,0 +1,210 @@ +package app + +import ( + "testing" + + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/stretchr/testify/require" +) + +func TestParseModelStr(t *testing.T) { + tests := []struct { + name string + modelStr string + expectedFilter string + expectedModelID string + setupProviders func() map[string]config.ProviderConfig + }{ + { + name: "simple model with no slashes", + modelStr: "gpt-4o", + expectedFilter: "", + expectedModelID: "gpt-4o", + setupProviders: setupMockProviders, + }, + { + name: "valid provider and model", + modelStr: "openai/gpt-4o", + expectedFilter: "openai", + expectedModelID: "gpt-4o", + setupProviders: setupMockProviders, + }, + { + name: "model with multiple slashes and first part is invalid provider", + modelStr: "moonshot/kimi-k2", + expectedFilter: "", + expectedModelID: "moonshot/kimi-k2", + setupProviders: setupMockProviders, + }, + { + name: "full path with valid provider and model with slashes", + modelStr: "synthetic/moonshot/kimi-k2", + expectedFilter: "synthetic", + expectedModelID: "moonshot/kimi-k2", + setupProviders: setupMockProvidersWithSlashes, + }, + { + name: "empty model string", + modelStr: "", + expectedFilter: "", + expectedModelID: "", + setupProviders: setupMockProviders, + }, + { + name: "model with trailing slash but valid provider", + modelStr: "openai/", + expectedFilter: "openai", + expectedModelID: "", + setupProviders: setupMockProviders, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + providers := tt.setupProviders() + filter, modelID := parseModelStr(providers, tt.modelStr) + + require.Equal(t, tt.expectedFilter, filter, "provider filter mismatch") + require.Equal(t, tt.expectedModelID, modelID, "model ID mismatch") + }) + } +} + +func setupMockProviders() map[string]config.ProviderConfig { + return map[string]config.ProviderConfig{ + "openai": { + ID: "openai", + Name: "OpenAI", + Models: []catwalk.Model{{ID: "gpt-4o"}, {ID: "gpt-4o-mini"}}, + }, + "anthropic": { + ID: "anthropic", + Name: "Anthropic", + Models: []catwalk.Model{{ID: "claude-3-sonnet"}, {ID: "claude-3-opus"}}, + }, + } +} + +func setupMockProvidersWithSlashes() map[string]config.ProviderConfig { + return map[string]config.ProviderConfig{ + "synthetic": { + ID: "synthetic", + Name: "Synthetic", + Models: []catwalk.Model{ + {ID: "moonshot/kimi-k2"}, + {ID: "deepseek/deepseek-chat"}, + }, + }, + "openai": { + ID: "openai", + Name: "OpenAI", + Models: []catwalk.Model{{ID: "gpt-4o"}}, + }, + } +} + +func TestFindModels(t *testing.T) { + tests := []struct { + name string + modelStr string + expectedProvider string + expectedModelID string + expectError bool + errorContains string + setupProviders func() map[string]config.ProviderConfig + }{ + { + name: "simple model found in one provider", + modelStr: "gpt-4o", + expectedProvider: "openai", + expectedModelID: "gpt-4o", + expectError: false, + setupProviders: setupMockProviders, + }, + { + name: "model with slashes in ID", + modelStr: "moonshot/kimi-k2", + expectedProvider: "synthetic", + expectedModelID: "moonshot/kimi-k2", + expectError: false, + setupProviders: setupMockProvidersWithSlashes, + }, + { + name: "provider and model with slashes in ID", + modelStr: "synthetic/moonshot/kimi-k2", + expectedProvider: "synthetic", + expectedModelID: "moonshot/kimi-k2", + expectError: false, + setupProviders: setupMockProvidersWithSlashes, + }, + { + name: "model not found", + modelStr: "nonexistent-model", + expectError: true, + errorContains: "not found", + setupProviders: setupMockProviders, + }, + { + name: "invalid provider specified", + modelStr: "nonexistent-provider/gpt-4o", + expectError: true, + errorContains: "provider", + setupProviders: setupMockProviders, + }, + { + name: "model found in multiple providers without provider filter", + modelStr: "shared-model", + expectError: true, + errorContains: "multiple providers", + setupProviders: func() map[string]config.ProviderConfig { + return map[string]config.ProviderConfig{ + "openai": { + ID: "openai", + Models: []catwalk.Model{{ID: "shared-model"}}, + }, + "anthropic": { + ID: "anthropic", + Models: []catwalk.Model{{ID: "shared-model"}}, + }, + } + }, + }, + { + name: "empty model string", + modelStr: "", + expectError: true, + errorContains: "not found", + setupProviders: setupMockProviders, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + providers := tt.setupProviders() + + // Use findModels with the model as "large" and empty "small". + matches, _, err := findModels(providers, tt.modelStr, "") + if err != nil { + if tt.expectError { + require.Contains(t, err.Error(), tt.errorContains) + } else { + require.NoError(t, err) + } + return + } + + // Validate the matches. + match, err := validateMatches(matches, tt.modelStr, "large") + + if tt.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorContains) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedProvider, match.provider) + require.Equal(t, tt.expectedModelID, match.modelID) + } + }) + } +} diff --git a/internal/cmd/models.go b/internal/cmd/models.go new file mode 100644 index 0000000000000000000000000000000000000000..3267469638ee83463e1785774d37c5d281d37de9 --- /dev/null +++ b/internal/cmd/models.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "fmt" + "os" + "slices" + "sort" + "strings" + + "charm.land/lipgloss/v2/tree" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" +) + +var modelsCmd = &cobra.Command{ + Use: "models", + Short: "List all available models from configured providers", + Long: `List all available models from configured providers. Shows provider name and model IDs.`, + Example: `# List all available models +crush models + +# Search models +crush models gpt5`, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cwd, err := ResolveCwd(cmd) + if err != nil { + return err + } + + dataDir, _ := cmd.Flags().GetString("data-dir") + debug, _ := cmd.Flags().GetBool("debug") + + cfg, err := config.Init(cwd, dataDir, debug) + if err != nil { + return err + } + + if !cfg.IsConfigured() { + return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively") + } + + term := strings.ToLower(strings.Join(args, " ")) + filter := func(p config.ProviderConfig, m catwalk.Model) bool { + for _, s := range []string{p.ID, p.Name, m.ID, m.Name} { + if term == "" || strings.Contains(strings.ToLower(s), term) { + return true + } + } + return false + } + + var providerIDs []string + providerModels := make(map[string][]string) + + for providerID, provider := range cfg.Providers.Seq2() { + if provider.Disable { + continue + } + var found bool + for _, model := range provider.Models { + if !filter(provider, model) { + continue + } + providerModels[providerID] = append(providerModels[providerID], model.ID) + found = true + } + if !found { + continue + } + slices.Sort(providerModels[providerID]) + providerIDs = append(providerIDs, providerID) + } + sort.Strings(providerIDs) + + if len(providerIDs) == 0 && len(args) == 0 { + return fmt.Errorf("no enabled providers found") + } + if len(providerIDs) == 0 { + return fmt.Errorf("no enabled providers found matching %q", term) + } + + if !isatty.IsTerminal(os.Stdout.Fd()) { + for _, providerID := range providerIDs { + for _, modelID := range providerModels[providerID] { + fmt.Println(providerID + "/" + modelID) + } + } + return nil + } + + t := tree.New() + for _, providerID := range providerIDs { + providerNode := tree.Root(providerID) + for _, modelID := range providerModels[providerID] { + providerNode.Child(modelID) + } + t.Child(providerNode) + } + + cmd.Println(t) + return nil + }, +} + +func init() { + rootCmd.AddCommand(modelsCmd) +} diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 6ebe5d79593bab6170e958ebdf26240d34327445..e4d72b41be13684e28ca6c2b85b79bfdcea52fc7 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -32,6 +32,8 @@ crush run --quiet "Generate a README for this project" `, RunE: func(cmd *cobra.Command, args []string) error { quiet, _ := cmd.Flags().GetBool("quiet") + largeModel, _ := cmd.Flags().GetString("model") + smallModel, _ := cmd.Flags().GetString("small-model") // Cancel on SIGINT or SIGTERM. ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) @@ -62,7 +64,7 @@ crush run --quiet "Generate a README for this project" event.SetNonInteractive(true) event.AppInitialized() - return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet) + return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet) }, PostRun: func(cmd *cobra.Command, args []string) { event.AppExited() @@ -71,4 +73,6 @@ crush run --quiet "Generate a README for this project" func init() { runCmd.Flags().BoolP("quiet", "q", false, "Hide spinner") + runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers") + runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider") } From dc17c7250a4fe3827ba8219ee0ae384828041bce Mon Sep 17 00:00:00 2001 From: mhpenta Date: Wed, 21 Jan 2026 13:50:53 -0700 Subject: [PATCH 180/335] Merge pull request #1886 from mhpenta/tui-window-title feat(tui): set window title to working directory --- internal/tui/tui.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 519b0e6aca73fb276d4137e00830b29798a391c7..7586cd8a494541e52a3e403fe0374b772bd7d332 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -16,6 +16,7 @@ import ( "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" "github.com/charmbracelet/crush/internal/stringext" @@ -592,6 +593,7 @@ func (a *appModel) View() tea.View { 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). From b93a3347acf294d59c338c8bad52d28ca9bce5ba Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 21 Jan 2026 15:53:12 -0500 Subject: [PATCH 181/335] feat(ui): show working directory in window title Related: https://github.com/charmbracelet/crush/pull/1886 --- internal/ui/model/ui.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 67eda91fd7fbcd60ead0e8216d347f1f38c0eb28..1c3320225b190d028a83c902f60c99c614023f3d 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -31,6 +31,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/pubsub" @@ -1717,6 +1718,7 @@ func (m *UI) View() tea.View { v.AltScreen = true v.BackgroundColor = m.com.Styles.Background v.MouseMode = tea.MouseModeCellMotion + v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir()) canvas := uv.NewScreenBuffer(m.width, m.height) v.Cursor = m.Draw(canvas, canvas.Bounds()) From e753d09af1e9279302388f7d4a15a7e7135b4396 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 22 Jan 2026 11:51:25 +0100 Subject: [PATCH 183/335] chore: do not scroll sessions if not neccessary this makes it so we only scroll if the selected item is not in the view, this was driving me nuts that it was always putting my last session at the top. --- internal/ui/dialog/sessions.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index a70d13ce58fed2ddf1b292d30e405362cf093569..9f7023a44e005c4d6ab735206824519e5b91e5bf 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -57,7 +57,6 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...) s.list.Focus() s.list.SetSelected(s.selectedSessionInx) - s.list.ScrollToSelected() s.input = textinput.New() s.input.SetVirtualCursor(false) @@ -153,6 +152,14 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { s.list.SetSize(innerWidth, height-heightOffset) s.help.SetWidth(innerWidth) + // This makes it so we do not scroll the list if we don't have to + start, end := s.list.VisibleItemIndices() + + // if selected index is outside visible range, scroll to it + if s.selectedSessionInx < start || s.selectedSessionInx > end { + s.list.ScrollToSelected() + } + rc := NewRenderContext(t, width) rc.Title = "Switch Session" inputView := t.Dialog.InputPrompt.Render(s.input.View()) From 1b61cd2b80d2aebe3f7ced80d944639a1ead6345 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 22 Jan 2026 11:51:38 +0100 Subject: [PATCH 184/335] chore: change the dialog sizes a bit --- internal/ui/dialog/commands.go | 11 ++++++++--- internal/ui/dialog/models.go | 5 ++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 9039a2457d3c86ba21886deac4137970f861fd59..66d924a28a64a7ad9dff3081878290b3f74cc230 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -28,7 +28,10 @@ type CommandType uint // String returns the string representation of the CommandType. func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] } -const sidebarCompactModeBreakpoint = 120 +const ( + sidebarCompactModeBreakpoint = 120 + defaultCommandsDialogMaxWidth = 70 +) const ( SystemCommands CommandType = iota @@ -238,7 +241,7 @@ func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds boo // Draw implements [Dialog]. func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := c.com.Styles - width := max(0, min(defaultDialogMaxWidth, area.Dx())) + width := max(0, min(defaultCommandsDialogMaxWidth, area.Dx())) height := max(0, min(defaultDialogHeight, area.Dy())) if area.Dx() != c.windowWidth && c.selected == SystemCommands { c.windowWidth = area.Dx() @@ -254,7 +257,9 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t.Dialog.View.GetVerticalFrameSize() c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding - c.list.SetSize(innerWidth, height-heightOffset) + + listHeight := min(height-heightOffset, c.list.Len()) + c.list.SetSize(innerWidth, listHeight) c.help.SetWidth(innerWidth) rc := NewRenderContext(t, width) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 77aeab22380f89455f83760428fac128fd4fc28b..31c0f0a886b72d409696f1a3a6c4305488ed9539 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -69,6 +69,8 @@ const ( // ModelsID is the identifier for the model selection dialog. const ModelsID = "models" +const defaultModelsDialogMaxWidth = 70 + // Models represents a model selection dialog. type Models struct { com *common.Common @@ -240,7 +242,7 @@ func (m *Models) modelTypeRadioView() string { // Draw implements [Dialog]. func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := m.com.Styles - width := max(0, min(defaultDialogMaxWidth, area.Dx())) + width := max(0, min(defaultModelsDialogMaxWidth, area.Dx())) height := max(0, min(defaultDialogHeight, area.Dy())) innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + @@ -442,6 +444,7 @@ func (m *Models) setProviderItems() error { // Set model groups in the list. m.list.SetGroups(groups...) m.list.SetSelectedItem(selectedItemID) + m.list.ScrollToSelected() // Update placeholder based on model type m.input.Placeholder = m.modelType.Placeholder() From 4e66b9c985cfa478dd7d75dcc8eb9bf65077f7d1 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 22 Jan 2026 11:51:53 +0100 Subject: [PATCH 185/335] refactor(ui): enable initialize --- internal/ui/model/onboarding.go | 25 +++++++++++++++++++------ internal/ui/model/ui.go | 1 + 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go index 1b922282ae78bf0a89004abfff6098ec3240ff94..d18469ee822460e60544a304afebb37dac7fa0d9 100644 --- a/internal/ui/model/onboarding.go +++ b/internal/ui/model/onboarding.go @@ -8,9 +8,12 @@ import ( "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/config" "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/uiutil" ) // markProjectInitialized marks the current project as initialized in the config. @@ -44,12 +47,22 @@ func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) { // initializeProject starts project initialization and transitions to the landing view. func (m *UI) initializeProject() tea.Cmd { - // TODO: initialize the project - // for now we just go to the landing page - m.state = uiLanding - m.focus = uiFocusEditor - // TODO: actually send a message to the agent - return m.markProjectInitialized + // clear the session + m.newSession() + cfg := m.com.Config() + var cmds []tea.Cmd + + initialize := func() tea.Msg { + initPrompt, err := agent.InitializePrompt(*cfg) + if err != nil { + return uiutil.InfoMsg{Type: uiutil.InfoTypeError, Msg: err.Error()} + } + return sendMessageMsg{Content: initPrompt} + } + // Mark the project as initialized + cmds = append(cmds, initialize, m.markProjectInitialized) + + return tea.Sequence(cmds...) } // skipInitializeProject skips project initialization and transitions to the landing view. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 1c3320225b190d028a83c902f60c99c614023f3d..0290ab3d44734b1eacc8ab31b244866086ae1f85 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1106,6 +1106,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { break } cmds = append(cmds, m.initializeProject()) + m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionSelectModel: if m.isAgentBusy() { From f002e6f855752dfa7fc197edbfaa84213771e675 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 22 Jan 2026 12:11:16 +0100 Subject: [PATCH 186/335] fix: lsp sort --- internal/ui/model/lsp.go | 9 ++++++++- internal/ui/model/ui.go | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go index 61e9f75d478ef51daee465ca7eeca109acd6c64b..de33142d51c720265ad84d317b83f5a997f69fac 100644 --- a/internal/ui/model/lsp.go +++ b/internal/ui/model/lsp.go @@ -23,8 +23,14 @@ type LSPInfo struct { func (m *UI) lspInfo(width, maxItems int, isSection bool) string { var lsps []LSPInfo t := m.com.Styles + lspConfigs := m.com.Config().LSP.Sorted() + + for _, cfg := range lspConfigs { + state, ok := m.lspStates[cfg.Name] + if !ok { + continue + } - for _, state := range m.lspStates { client, ok := m.com.App.LSPClients.Get(state.Name) if !ok { continue @@ -39,6 +45,7 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string { lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs}) } + title := t.Subtle.Render("LSPs") if isSection { title = common.Section(t, title, width) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 0290ab3d44734b1eacc8ab31b244866086ae1f85..01165860f85d85bc9d26a0554c4861e4063b8cb7 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -265,6 +265,8 @@ func New(com *common.Common) *UI { completions: comp, attachments: attachments, todoSpinner: todoSpinner, + lspStates: make(map[string]app.LSPClientInfo), + mcpStates: make(map[string]mcp.ClientInfo), } status := NewStatus(com, ui) From 891b5353207386f5b3753380dbb41105d2f132bf Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 22 Jan 2026 15:19:07 +0100 Subject: [PATCH 187/335] fix: tab to chat only when in chat --- internal/ui/model/ui.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 01165860f85d85bc9d26a0554c4861e4063b8cb7..94fdfe2a2d550ddbce4b12573b64eceedd33d93a 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1433,10 +1433,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } m.newSession() case key.Matches(msg, m.keyMap.Tab): - m.focus = uiFocusMain - m.textarea.Blur() - m.chat.Focus() - m.chat.SetSelected(m.chat.Len() - 1) + if m.state != uiLanding { + m.focus = uiFocusMain + m.textarea.Blur() + m.chat.Focus() + m.chat.SetSelected(m.chat.Len() - 1) + } case key.Matches(msg, m.keyMap.Editor.OpenEditor): if m.isAgentBusy() { cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait...")) From 698b7c8049bd0bb75fccc47c11d472cb500b16ba Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 22 Jan 2026 15:25:17 +0100 Subject: [PATCH 188/335] fix: handle new session when focused on the list --- internal/ui/model/keys.go | 4 ++-- internal/ui/model/ui.go | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index 053c30aaa1b51b1fd04bc8a3e754460519336359..6e21e4dee0dbae1dffc124066b01185c7ebc9d3a 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -166,11 +166,11 @@ func DefaultKeyMap() KeyMap { ) km.Chat.Down = key.NewBinding( - key.WithKeys("down", "ctrl+j", "ctrl+n", "j"), + key.WithKeys("down", "ctrl+j", "j"), key.WithHelp("↓", "down"), ) km.Chat.Up = key.NewBinding( - key.WithKeys("up", "ctrl+k", "ctrl+p", "k"), + key.WithKeys("up", "ctrl+k", "k"), key.WithHelp("↑", "up"), ) km.Chat.UpDown = key.NewBinding( diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 94fdfe2a2d550ddbce4b12573b64eceedd33d93a..634b410ef74d5eb00e9d6e55dd1a87cc850c5d86 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1511,6 +1511,16 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.focus = uiFocusEditor cmds = append(cmds, m.textarea.Focus()) m.chat.Blur() + case key.Matches(msg, m.keyMap.Chat.NewSession): + if !m.hasSession() { + break + } + if m.isAgentBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) + break + } + m.focus = uiFocusEditor + m.newSession() case key.Matches(msg, m.keyMap.Chat.Expand): m.chat.ToggleExpandedSelectedItem() case key.Matches(msg, m.keyMap.Chat.Up): From 4cf6af059742f4ed8ba58e1e24ca15bfb0f8aea5 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 22 Jan 2026 15:28:49 +0100 Subject: [PATCH 189/335] fix: make sure we have a fresh model/tools on each call --- internal/agent/coordinator.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 943c3efc41b33ea9f261b4ffc7256b6f544beff9..fd6662c1961c7f5f8aa6289b38e89b0aa9dc521a 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -117,6 +117,11 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, return nil, err } + // refresh models before each run + if err := c.UpdateModels(ctx); err != nil { + return nil, fmt.Errorf("failed to update models: %w", err) + } + model := c.currentAgent.Model() maxTokens := model.CatwalkCfg.DefaultMaxTokens if model.ModelCfg.MaxTokens != 0 { From 1fad83be4a3fb73b18047e00a68af5e3b29e3322 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 22 Jan 2026 15:35:38 +0100 Subject: [PATCH 190/335] fix: add back suspend --- internal/ui/model/ui.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 634b410ef74d5eb00e9d6e55dd1a87cc850c5d86..fa1ffed72bd02a38a00b187cbf749cc2c74af2b1 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1332,6 +1332,13 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } return true } + case key.Matches(msg, m.keyMap.Suspend): + if m.isAgentBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) + return true + } + cmds = append(cmds, tea.Suspend) + return true } return false } From 8ebe91437c994ec0f88c005497b26feaad3e58c8 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 22 Jan 2026 16:01:43 +0100 Subject: [PATCH 191/335] refactor(ui): add references tool (#1940) --- internal/ui/chat/references.go | 63 ++++++++++++++++++++++++++++++++++ internal/ui/chat/tools.go | 2 ++ 2 files changed, 65 insertions(+) create mode 100644 internal/ui/chat/references.go diff --git a/internal/ui/chat/references.go b/internal/ui/chat/references.go new file mode 100644 index 0000000000000000000000000000000000000000..2d7efe8df3ed38bf3768d7ae13c433fc05c17418 --- /dev/null +++ b/internal/ui/chat/references.go @@ -0,0 +1,63 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ReferencesToolMessageItem is a message item that represents a references tool call. +type ReferencesToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*ReferencesToolMessageItem)(nil) + +// NewReferencesToolMessageItem creates a new [ReferencesToolMessageItem]. +func NewReferencesToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &ReferencesToolRenderContext{}, canceled) +} + +// ReferencesToolRenderContext renders references tool messages. +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) + } + + var params tools.ReferencesParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + toolParams := []string{params.Symbol} + if params.Path != "" { + toolParams = append(toolParams, "path", fsext.PrettyPath(params.Path)) + } + + header := toolHeader(sty, opts.Status, "Find References", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + 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/tools.go b/internal/ui/chat/tools.go index e703cb1c096c0fa889438a446a8f042b892d9e31..1c9f71eb41168d913f4193af896ecb1b389136e7 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -242,6 +242,8 @@ func NewToolMessageItem( item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled) case tools.TodosToolName: item = NewTodosToolMessageItem(sty, toolCall, result, canceled) + case tools.ReferencesToolName: + item = NewReferencesToolMessageItem(sty, toolCall, result, canceled) default: if strings.HasPrefix(toolCall.Name, "mcp_") { item = NewMCPToolMessageItem(sty, toolCall, result, canceled) From 676c0382db253bc417e2380c72b7d499b6942b8c Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 22 Jan 2026 10:20:50 -0500 Subject: [PATCH 192/335] fix(ui): models: ensure select loop breaks correctly and scroll to top on filter --- internal/ui/dialog/models.go | 4 +++- internal/ui/dialog/models_list.go | 22 +++++++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 31c0f0a886b72d409696f1a3a6c4305488ed9539..ed42d388fde959c41017217fc279fc175a9e0f49 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -206,8 +206,10 @@ func (m *Models) HandleMsg(msg tea.Msg) Action { var cmd tea.Cmd m.input, cmd = m.input.Update(msg) value := m.input.Value() + m.list.Focus() m.list.SetFilter(value) - m.list.ScrollToSelected() + m.list.SelectFirst() + m.list.ScrollToTop() return ActionCmd{cmd} } } diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index c0eaba437154a78df3865ab9cd0e96c5c9c57321..cd2f2a9570d8676ded81384a0720325c52e6e232 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -57,6 +57,7 @@ func (f *ModelsList) SetGroups(groups ...ModelGroup) { // SetFilter sets the filter query and updates the list items. func (f *ModelsList) SetFilter(q string) { f.query = q + f.SetItems(f.VisibleItems()...) } // SetSelected sets the selected item index. It overrides the base method to @@ -103,49 +104,56 @@ func (f *ModelsList) SetSelectedItem(itemID string) { // SelectNext selects the next model item, skipping any non-focusable items // like group headers and spacers. func (f *ModelsList) SelectNext() (v bool) { - for { - v = f.List.SelectNext() + v = f.List.SelectNext() + for v { selectedItem := f.SelectedItem() if _, ok := selectedItem.(*ModelItem); ok { return v } + v = f.List.SelectNext() } + return v } // SelectPrev selects the previous model item, skipping any non-focusable items // like group headers and spacers. func (f *ModelsList) SelectPrev() (v bool) { - for { - v = f.List.SelectPrev() + v = f.List.SelectPrev() + for v { selectedItem := f.SelectedItem() if _, ok := selectedItem.(*ModelItem); ok { return v } + v = f.List.SelectPrev() } + return v } // SelectFirst selects the first model item in the list. func (f *ModelsList) SelectFirst() (v bool) { v = f.List.SelectFirst() - for { + for v { selectedItem := f.SelectedItem() - if _, ok := selectedItem.(*ModelItem); ok { + _, ok := selectedItem.(*ModelItem) + if ok { return v } v = f.List.SelectNext() } + return v } // SelectLast selects the last model item in the list. func (f *ModelsList) SelectLast() (v bool) { v = f.List.SelectLast() - for { + for v { selectedItem := f.SelectedItem() if _, ok := selectedItem.(*ModelItem); ok { return v } v = f.List.SelectPrev() } + return v } // IsSelectedFirst checks if the selected item is the first model item. From 8d55cf71885893c8077586a272d0a4053c320c90 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 22 Jan 2026 12:58:44 -0300 Subject: [PATCH 193/335] chore(legal): @huaiyuWangh 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 5929987f916594da1109eee2082c154620edf660..a25449a3269f1d52faf74fc10b423fb9eb17f347 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1071,6 +1071,14 @@ "created_at": "2026-01-14T14:02:04Z", "repoId": 987670088, "pullRequestNo": 1870 + }, + { + "name": "huaiyuWangh", + "id": 34158348, + "comment_id": 3785195950, + "created_at": "2026-01-22T15:58:33Z", + "repoId": 987670088, + "pullRequestNo": 1943 } ] } \ No newline at end of file From b50d5799ac5642af2ce58c72922685e319768caf Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 22 Jan 2026 12:29:17 -0500 Subject: [PATCH 194/335] fix(ui): only copy chat highlight when we have highlighted content --- internal/ui/model/ui.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index fa1ffed72bd02a38a00b187cbf749cc2c74af2b1..0f59e10bee7a6c7ed3a12ef2edae8cf74f5de5d6 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -549,7 +549,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Adjust for chat area position x -= m.layout.main.Min.X y -= m.layout.main.Min.Y - if m.chat.HandleMouseUp(x, y) { + if m.chat.HandleMouseUp(x, y) && m.chat.HasHighlight() { cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg { if time.Since(m.lastClickTime) >= doubleClickThreshold { return copyChatHighlightMsg{} From c929030a6069fb71df91fd959093345514deec75 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:58:09 -0300 Subject: [PATCH 195/335] chore(legal): @akitaonrails 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 a25449a3269f1d52faf74fc10b423fb9eb17f347..f003ecbf95d29993d7f8c1fdf13f6da3c4090058 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1079,6 +1079,14 @@ "created_at": "2026-01-22T15:58:33Z", "repoId": 987670088, "pullRequestNo": 1943 + }, + { + "name": "akitaonrails", + "id": 2840, + "comment_id": 3786408984, + "created_at": "2026-01-22T19:57:59Z", + "repoId": 987670088, + "pullRequestNo": 1945 } ] } \ No newline at end of file From e1c339682dd2a8dca7e9a1aa25daade4bf4b1e27 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 22 Jan 2026 21:45:06 -0300 Subject: [PATCH 196/335] chore(legal): @mcowger 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 f003ecbf95d29993d7f8c1fdf13f6da3c4090058..915cfe8d6ed841a0e84d5f69b737e7536e2f3732 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1087,6 +1087,14 @@ "created_at": "2026-01-22T19:57:59Z", "repoId": 987670088, "pullRequestNo": 1945 + }, + { + "name": "mcowger", + "id": 1929548, + "comment_id": 3787591535, + "created_at": "2026-01-23T00:44:49Z", + "repoId": 987670088, + "pullRequestNo": 1950 } ] } \ No newline at end of file From 5a774dd4ffdf1a567ef0e2558abac436d83813b1 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:01:04 -0300 Subject: [PATCH 197/335] chore(legal): @jerilynzheng 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 915cfe8d6ed841a0e84d5f69b737e7536e2f3732..533ee23bab1bc33b34c5eaa189dd973b3c355046 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1095,6 +1095,14 @@ "created_at": "2026-01-23T00:44:49Z", "repoId": 987670088, "pullRequestNo": 1950 + }, + { + "name": "jerilynzheng", + "id": 15837981, + "comment_id": 3788071777, + "created_at": "2026-01-23T04:00:52Z", + "repoId": 987670088, + "pullRequestNo": 1951 } ] } \ No newline at end of file From da6f51e388663f93ad6e7fdcc14ee0ac04997681 Mon Sep 17 00:00:00 2001 From: Jerilyn Zheng Date: Fri, 23 Jan 2026 05:10:46 -0800 Subject: [PATCH 198/335] docs: add vercel ai gateway to readme (#1951) Co-authored-by: Claude Sonnet 4.5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8caf83d4562c4afbd72f87487548792b337da419..f8028ed22b4e45080ac975282b29c8c11aa5cd97 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ go install github.com/charmbracelet/crush@latest ## Getting Started The quickest way to get started is to grab an API key for your preferred -provider such as Anthropic, OpenAI, Groq, or OpenRouter and just start +provider such as Anthropic, OpenAI, Groq, OpenRouter, or Vercel AI Gateway and just start Crush. You'll be prompted to enter your API key. That said, you can also set environment variables for preferred providers. @@ -184,6 +184,7 @@ That said, you can also set environment variables for preferred providers. | `ANTHROPIC_API_KEY` | Anthropic | | `OPENAI_API_KEY` | OpenAI | | `OPENROUTER_API_KEY` | OpenRouter | +| `VERCEL_API_KEY` | Vercel AI Gateway | | `GEMINI_API_KEY` | Google Gemini | | `CEREBRAS_API_KEY` | Cerebras | | `HF_TOKEN` | Huggingface Inference | From eb2e46e32f6f59af202c3fca88d11d1062069e0c Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 23 Jan 2026 10:26:17 -0300 Subject: [PATCH 199/335] chore(deps): catwalk update Signed-off-by: Carlos Alexandro Becker --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 770cc9e8e1f6374c8552cf618562d2c3ce81f852..15ab8949dbb96e138e82a8f4cf9700d4dc2d08df 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.9.2 github.com/charlievieth/fastwalk v1.0.14 - github.com/charmbracelet/catwalk v0.14.2 + github.com/charmbracelet/catwalk v0.15.0 github.com/charmbracelet/colorprofile v0.4.1 github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 diff --git a/go.sum b/go.sum index 7c720ae03b5354fc3dd148b1033b3efec118999a..6cfb9f824aca144f6973cb689677d181387daa3b 100644 --- a/go.sum +++ b/go.sum @@ -96,8 +96,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw= github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4= -github.com/charmbracelet/catwalk v0.14.2 h1:7st55IXbMbOaj8/m4Ceb0Ppvz6M879FfYK3e4loUhic= -github.com/charmbracelet/catwalk v0.14.2/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ= +github.com/charmbracelet/catwalk v0.15.0 h1:5oWJdvchTPfF7855A0n40+XbZQz4+vouZ/NhQ661JKI= +github.com/charmbracelet/catwalk v0.15.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= From 138275336dd7523e73a3d576d053e0491bce7760 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 23 Jan 2026 15:29:42 +0100 Subject: [PATCH 200/335] fix: route mouse events to the dialog if its showing (#1953) --- internal/ui/model/ui.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 0f59e10bee7a6c7ed3a12ef2edae8cf74f5de5d6..78846fd86c2f271f1293290e126ea7e5d1c73b87 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -497,6 +497,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case copyChatHighlightMsg: cmds = append(cmds, m.copyChatHighlight()) case tea.MouseClickMsg: + // Pass mouse events to dialogs first if any are open. + if m.dialog.HasDialogs() { + m.dialog.Update(msg) + return m, tea.Batch(cmds...) + } switch m.state { case uiChat: x, y := msg.X, msg.Y @@ -509,6 +514,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.MouseMotionMsg: + // Pass mouse events to dialogs first if any are open. + if m.dialog.HasDialogs() { + m.dialog.Update(msg) + return m, tea.Batch(cmds...) + } + switch m.state { case uiChat: if msg.Y <= 0 { @@ -541,6 +552,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.MouseReleaseMsg: + // Pass mouse events to dialogs first if any are open. + if m.dialog.HasDialogs() { + m.dialog.Update(msg) + return m, tea.Batch(cmds...) + } const doubleClickThreshold = 500 * time.Millisecond switch m.state { From 9604c92efbc0f6a97ac3dbcdc85fb7abf8967e99 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 23 Jan 2026 15:29:53 +0100 Subject: [PATCH 201/335] fix: commands height (#1954) --- internal/ui/dialog/commands.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 66d924a28a64a7ad9dff3081878290b3f74cc230..179797fb9c094bb81253ae560fc738b6ea536cb2 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -258,8 +258,7 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding - listHeight := min(height-heightOffset, c.list.Len()) - c.list.SetSize(innerWidth, listHeight) + c.list.SetSize(innerWidth, height-heightOffset) c.help.SetWidth(innerWidth) rc := NewRenderContext(t, width) From 945006a58431cebc294c7ee567ba6868f21735b0 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 23 Jan 2026 15:30:02 +0100 Subject: [PATCH 202/335] fix: permission notification (#1955) --- internal/permission/permission.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/permission/permission.go b/internal/permission/permission.go index 2209e26ea924535598982c5158900b5a93dc0a21..fc47b7dc93869a1b0a39d30ddb0e408ce479429f 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -152,6 +152,10 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe s.autoApproveSessionsMu.RUnlock() if autoApprove { + s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{ + ToolCallID: opts.ToolCallID, + Granted: true, + }) return true, nil } @@ -183,6 +187,10 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe for _, p := range s.sessionPermissions { if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path { s.sessionPermissionsMu.RUnlock() + s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{ + ToolCallID: opts.ToolCallID, + Granted: true, + }) return true, nil } } From fb3eeb010283210d3a60e3ed17e84b8dcd27821c Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 23 Jan 2026 15:47:09 +0100 Subject: [PATCH 203/335] fix: completions width (#1956) * fix: completions width * refactor: rename visible items to filtered items (#1957) the name VisibleItems is misleading because it does not take into account the height of the list and returns all items that match the filter. --- internal/ui/completions/completions.go | 38 +++++++++++++++++--------- internal/ui/dialog/commands.go | 2 +- internal/ui/dialog/reasoning.go | 2 +- internal/ui/list/filterable.go | 8 +++--- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go index 4a4f9d8133491b8a7b80df6066b9e86c7e852a85..66389e3b99c09123334c1685bd8e22e4d7354ed1 100644 --- a/internal/ui/completions/completions.go +++ b/internal/ui/completions/completions.go @@ -83,7 +83,7 @@ func (c *Completions) Query() string { // Size returns the visible size of the popup. func (c *Completions) Size() (width, height int) { - visible := len(c.list.VisibleItems()) + visible := len(c.list.FilteredItems()) return c.width, min(visible, c.height) } @@ -104,7 +104,6 @@ func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd { // SetFiles sets the file items on the completions popup. func (c *Completions) SetFiles(files []string) { items := make([]list.FilterableItem, 0, len(files)) - width := 0 for _, file := range files { file = strings.TrimPrefix(file, "./") item := NewCompletionItem( @@ -114,8 +113,6 @@ func (c *Completions) SetFiles(files []string) { c.focusedStyle, c.matchStyle, ) - - width = max(width, ansi.StringWidth(file)) items = append(items, item) } @@ -125,11 +122,22 @@ func (c *Completions) SetFiles(files []string) { c.list.SetFilter("") // Clear any previous filter. c.list.Focus() - c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth)) + c.width = maxWidth c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight)) c.list.SetSize(c.width, c.height) 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) } // Close closes the completions popup. @@ -150,10 +158,14 @@ func (c *Completions) Filter(query string) { c.query = query c.list.SetFilter(query) - items := c.list.VisibleItems() + // recalculate width by using just the visible items + items := c.list.FilteredItems() + start, end := c.list.VisibleItemIndices() width := 0 - for _, item := range items { - width = max(width, ansi.StringWidth(item.(interface{ Text() string }).Text())) + if end != 0 { + for _, item := range items[start : end+1] { + width = max(width, ansi.StringWidth(item.(interface{ Text() string }).Text())) + } } c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth)) c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight)) @@ -164,7 +176,7 @@ func (c *Completions) Filter(query string) { // HasItems returns whether there are visible items. func (c *Completions) HasItems() bool { - return len(c.list.VisibleItems()) > 0 + return len(c.list.FilteredItems()) > 0 } // Update handles key events for the completions. @@ -203,7 +215,7 @@ func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) { // selectPrev selects the previous item with circular navigation. func (c *Completions) selectPrev() { - items := c.list.VisibleItems() + items := c.list.FilteredItems() if len(items) == 0 { return } @@ -215,7 +227,7 @@ func (c *Completions) selectPrev() { // selectNext selects the next item with circular navigation. func (c *Completions) selectNext() { - items := c.list.VisibleItems() + items := c.list.FilteredItems() if len(items) == 0 { return } @@ -227,7 +239,7 @@ func (c *Completions) selectNext() { // selectCurrent returns a command with the currently selected item. func (c *Completions) selectCurrent(insert bool) tea.Msg { - items := c.list.VisibleItems() + items := c.list.FilteredItems() if len(items) == 0 { return nil } @@ -258,7 +270,7 @@ func (c *Completions) Render() string { return "" } - items := c.list.VisibleItems() + items := c.list.FilteredItems() if len(items) == 0 { return "" } diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 179797fb9c094bb81253ae560fc738b6ea536cb2..b5058e50f9c5bae17acd76994115597e59ee912c 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -188,7 +188,7 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action { } default: var cmd tea.Cmd - for _, item := range c.list.VisibleItems() { + for _, item := range c.list.FilteredItems() { if item, ok := item.(*CommandItem); ok && item != nil { if msg.String() == item.Shortcut() { return item.Action() diff --git a/internal/ui/dialog/reasoning.go b/internal/ui/dialog/reasoning.go index 258c5c77470380478a2ffab9af89db195c849d32..7ccb575f55258000fe6246e1fac42cbb1553174a 100644 --- a/internal/ui/dialog/reasoning.go +++ b/internal/ui/dialog/reasoning.go @@ -176,7 +176,7 @@ func (r *Reasoning) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { inputView := t.Dialog.InputPrompt.Render(r.input.View()) rc.AddPart(inputView) - visibleCount := len(r.list.VisibleItems()) + visibleCount := len(r.list.FilteredItems()) if r.list.Height() >= visibleCount { r.list.ScrollToTop() } else { diff --git a/internal/ui/list/filterable.go b/internal/ui/list/filterable.go index ed018e2e1e26e7d5a5bbc091b17c572331811dc5..1c66cf0f1351a40e3cadcb535803d7252785d4fb 100644 --- a/internal/ui/list/filterable.go +++ b/internal/ui/list/filterable.go @@ -69,7 +69,7 @@ func (f *FilterableList) PrependItems(items ...FilterableItem) { // SetFilter sets the filter query and updates the list items. func (f *FilterableList) SetFilter(q string) { f.query = q - f.List.SetItems(f.VisibleItems()...) + f.List.SetItems(f.FilteredItems()...) f.ScrollToTop() } @@ -87,8 +87,8 @@ func (f FilterableItemsSource) String(i int) string { return f[i].Filter() } -// VisibleItems returns the visible items after filtering. -func (f *FilterableList) VisibleItems() []Item { +// FilteredItems returns the visible items after filtering. +func (f *FilterableList) FilteredItems() []Item { if f.query == "" { items := make([]Item, len(f.items)) for i, item := range f.items { @@ -120,6 +120,6 @@ func (f *FilterableList) VisibleItems() []Item { // Render renders the filterable list. func (f *FilterableList) Render() string { - f.List.SetItems(f.VisibleItems()...) + f.List.SetItems(f.FilteredItems()...) return f.List.Render() } From 6ba301e6e1214eb8f8f5fcf2d51af0781d8ef7be Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 23 Jan 2026 15:47:23 +0100 Subject: [PATCH 204/335] fix: new/update message behavior (#1958) --- internal/ui/list/list.go | 26 ++++++++++++++++++++++++++ internal/ui/model/chat.go | 4 ++-- internal/ui/model/ui.go | 20 ++++++++++++++------ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 78cb437d361f8ec05d81acfa98f2e87a23755d58..6b9fbf45b0dfcdc310a014b42eb04950aa891a71 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -75,6 +75,32 @@ func (l *List) Gap() int { return l.gap } +// AtBottom returns whether the list is scrolled to the bottom. +func (l *List) AtBottom() bool { + if len(l.items) == 0 { + return true + } + + // Calculate total height of all items from the bottom. + var totalHeight int + for i := len(l.items) - 1; i >= 0; i-- { + item := l.getItem(i) + totalHeight += item.height + if l.gap > 0 && i < len(l.items)-1 { + totalHeight += l.gap + } + if totalHeight >= l.height { + // This is the expected bottom position. + expectedIdx := i + expectedLine := totalHeight - l.height + return l.offsetIdx == expectedIdx && l.offsetLine >= expectedLine + } + } + + // All items fit in viewport - we're at bottom if at top. + return l.offsetIdx == 0 && l.offsetLine == 0 +} + // SetReverse shows the list in reverse order. func (l *List) SetReverse(reverse bool) { l.reverse = reverse diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 9ab77c60f6b1a48228c5d08ae4d9827584b62e6d..f3388085d1808d984ed4b90bdeaeb58d71cb2463 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -481,9 +481,9 @@ func (m *Chat) HasHighlight() bool { return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol) } -// HighlighContent returns the currently highlighted content based on the mouse +// HighlightContent returns the currently highlighted content based on the mouse // selection. It returns an empty string if no content is highlighted. -func (m *Chat) HighlighContent() string { +func (m *Chat) HighlightContent() string { startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange() if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol { return "" diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 78846fd86c2f271f1293290e126ea7e5d1c73b87..a104168c58422d01363850976ab68f1f99d90137 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -873,6 +873,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { var cmds []tea.Cmd existingItem := m.chat.MessageItem(msg.ID) + atBottom := m.chat.list.AtBottom() if existingItem != nil { if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok { @@ -893,9 +894,6 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil { newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0)) m.chat.AppendMessages(newInfoItem) - if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { - cmds = append(cmds, cmd) - } } } @@ -922,9 +920,12 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { } } } + m.chat.AppendMessages(items...) - if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { - cmds = append(cmds, cmd) + if atBottom { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } } return tea.Batch(cmds...) @@ -934,6 +935,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd { var cmds []tea.Cmd + atBottom := m.chat.list.AtBottom() // Only process messages with tool calls or results. if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 { return nil @@ -1013,6 +1015,12 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea. // Update the chat so it updates the index map for animations to work as expected m.chat.UpdateNestedToolIDs(toolCallID) + if atBottom { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + return tea.Batch(cmds...) } @@ -2905,7 +2913,7 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string } func (m *UI) copyChatHighlight() tea.Cmd { - text := m.chat.HighlighContent() + text := m.chat.HighlightContent() return tea.Sequence( tea.SetClipboard(text), func() tea.Msg { From 505283a22e75b2e6420ae90777527f23c834347a Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 23 Jan 2026 09:56:51 -0500 Subject: [PATCH 205/335] fix(ui): rework cursor can appear out of place on multi-line (#1948) Ensure we update the textarea after inserting a newline to keep the cursor position accurate. --- 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 a104168c58422d01363850976ab68f1f99d90137..8bdb9bbf5dc34653597d3d6d8ad0a92b1cfdba7c 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1479,6 +1479,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { case key.Matches(msg, m.keyMap.Editor.Newline): m.textarea.InsertRune('\n') m.closeCompletions() + ta, cmd := m.textarea.Update(msg) + m.textarea = ta + cmds = append(cmds, cmd) default: if handleGlobalKeys(msg) { // Handle global keys first before passing to textarea. From 49a41de935eb6ae77eff4a9f3295055eec51c162 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 23 Jan 2026 15:58:30 +0100 Subject: [PATCH 206/335] refactor: use different ansi image library (#1964) --- go.mod | 3 ++- go.sum | 6 ++++-- internal/ui/image/image.go | 38 +++++++++++++++++++++++++++++++++++--- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 15ab8949dbb96e138e82a8f4cf9700d4dc2d08df..d4f09c84d627ed5991e6b9db5aff2e41009a98c0 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,6 @@ 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.0.0-20260119114936-fd556377ea59 - github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383 github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b github.com/charmbracelet/x/term v0.2.2 github.com/denisbrodbeck/machineid v1.0.1 @@ -41,6 +40,7 @@ require ( github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 + github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d 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 @@ -122,6 +122,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/jsonschema-go v0.3.0 // indirect github.com/google/s2a-go v0.1.9 // indirect diff --git a/go.sum b/go.sum index 6cfb9f824aca144f6973cb689677d181387daa3b..aade1be477d587b00e8512c02e8a1164d8937a85 100644 --- a/go.sum +++ b/go.sum @@ -122,8 +122,6 @@ github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59 h1:cvP github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59/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/mosaic v0.0.0-20251215102626-e0db08df7383 h1:YpTd2/abobMn/dCRM6Vo+G7JO/VS6RW0Ln3YkVJih8Y= -github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383/go.mod h1:r+fiJS0jb0Z5XKO+1mgKbwbPWzTy8e2dMjBMqa+XqsY= github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8= github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -189,6 +187,8 @@ github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -221,6 +221,8 @@ github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcI github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:on25kP+Sx7sxUMRQiA8gdcToAGet4DK/EIA30mXre+4= +github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d/go.mod h1:SV0W0APWP9MZ1/gfDQ/NzzTlWdIgYZ/ZbpN4d/UXRYw= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kaptinlin/go-i18n v0.2.2 h1:kebVCZme/BrCTqonh/J+VYCl1+Of5C18bvyn3DRPl5M= github.com/kaptinlin/go-i18n v0.2.2/go.mod h1:MiwkeHryBopAhC/M3zEwIM/2IN8TvTqJQswPw6kceqM= diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index 06183ae8142b6d7f2e4ff932cdfa07273f1a16c8..6af76531ff5b542f180e38fb7db105e4a86b49b6 100644 --- a/internal/ui/image/image.go +++ b/internal/ui/image/image.go @@ -16,8 +16,8 @@ import ( uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi/kitty" - "github.com/charmbracelet/x/mosaic" "github.com/disintegration/imaging" + paintbrush "github.com/jordanella/go-ansi-paintbrush" ) // Capabilities represents the capabilities of displaying images on the @@ -246,8 +246,40 @@ func (e Encoding) Render(id string, cols, rows int) string { switch e { case EncodingBlocks: - m := mosaic.New().Width(cols).Height(rows).Scale(1) - return strings.TrimSpace(m.Render(img)) + canvas := paintbrush.New() + canvas.SetImage(img) + canvas.SetWidth(cols) + canvas.SetHeight(rows) + canvas.Weights = map[rune]float64{ + '': .95, + '': .95, + '▁': .9, + '▂': .9, + '▃': .9, + '▄': .9, + '▅': .9, + '▆': .85, + '█': .85, + '▊': .95, + '▋': .95, + '▌': .95, + '▍': .95, + '▎': .95, + '▏': .95, + '●': .95, + '◀': .95, + '▲': .95, + '▶': .95, + '▼': .9, + '○': .8, + '◉': .95, + '◧': .9, + '◨': .9, + '◩': .9, + '◪': .9, + } + canvas.Paint() + return strings.TrimSpace(canvas.GetResult()) case EncodingKitty: // Build Kitty graphics unicode place holders var fg color.Color From c682e1c1007b9983f6190edcc900134a93d969fe Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 23 Jan 2026 12:08:46 -0300 Subject: [PATCH 207/335] ci: goreleaser build --snapshot on every commit to main (#1910) * ci: goreleaser build --snapshot on every commit to main Signed-off-by: Carlos Alexandro Becker * chore: add preconditions Signed-off-by: Carlos Alexandro Becker * fix: faster runners Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- .github/workflows/snapshot.yml | 33 +++++++++++++++++++++++++++++++++ Taskfile.yaml | 4 ++++ 2 files changed, 37 insertions(+) create mode 100644 .github/workflows/snapshot.yml diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml new file mode 100644 index 0000000000000000000000000000000000000000..a033e082bfc1a109f222f2ae9d57731f7b34318d --- /dev/null +++ b/.github/workflows/snapshot.yml @@ -0,0 +1,33 @@ +name: snapshot + +on: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: snapshot-${{ github.ref }} + cancel-in-progress: true + +jobs: + snapshot: + runs-on: + # Use our own large runners with more CPU and RAM for faster builds + group: releasers + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version-file: go.mod + - uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 + with: + version: "~> v2" + args: build --snapshot --clean + env: + GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} diff --git a/Taskfile.yaml b/Taskfile.yaml index 0043f4f033e455a5800da2431848e620c37a0f5a..b626c2fdd767d4da40b192de9e454fb8f2050afd 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -122,6 +122,10 @@ tasks: msg: Not on main branch - sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]" msg: "Git is dirty" + - sh: 'gh run list --workflow build.yml --commit $(git rev-parse HEAD) --status success --json conclusion -q ".[0].conclusion" | grep -q success' + msg: "Test build for this commit failed or not present" + - sh: 'gh run list --workflow snapshot.yml --commit $(git rev-parse HEAD) --status success --json conclusion -q ".[0].conclusion" | grep -q success' + msg: "Snapshot build for this commit failed or not present" cmds: - task: fetch-tags - git commit --allow-empty -m "{{.NEXT}}" From 0d4cbf82ab6e3b93696e01d075357c2c3331ddfa Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 23 Jan 2026 13:55:07 -0300 Subject: [PATCH 208/335] feat: lsp_restart (#1930) * feat: lsp_restart Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * fix: typo Signed-off-by: Carlos Alexandro Becker * fix: actually restart Signed-off-by: Carlos Alexandro Becker * fix: simplify Signed-off-by: Carlos Alexandro Becker * fix: render lsp restart Signed-off-by: Carlos Alexandro Becker * fix: add lsp name to diag Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- internal/agent/coordinator.go | 2 +- internal/agent/tools/diagnostics.go | 6 +- internal/agent/tools/lsp_restart.go | 80 ++++++++++++++ internal/agent/tools/lsp_restart.md | 25 +++++ internal/config/config.go | 1 + internal/config/load_test.go | 4 +- internal/lsp/client.go | 162 +++++++++++++++++++--------- internal/ui/chat/lsp_restart.go | 62 +++++++++++ internal/ui/chat/tools.go | 2 + 9 files changed, 285 insertions(+), 59 deletions(-) create mode 100644 internal/agent/tools/lsp_restart.go create mode 100644 internal/agent/tools/lsp_restart.md create mode 100644 internal/ui/chat/lsp_restart.go diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index fd6662c1961c7f5f8aa6289b38e89b0aa9dc521a..8c2a785b2f8ffeb77bbf52bb9653e8a98369303b 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -406,7 +406,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan ) if len(c.cfg.LSP) > 0 { - allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients)) + allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients)) } var filteredTools []fantasy.AgentTool diff --git a/internal/agent/tools/diagnostics.go b/internal/agent/tools/diagnostics.go index 85e8b8d0f7d997f8db83f0d6176ce30c644b86f0..9af0da43c396d9fa8aa9776f4f7fb177af6b5806 100644 --- a/internal/agent/tools/diagnostics.go +++ b/internal/agent/tools/diagnostics.go @@ -137,11 +137,9 @@ func formatDiagnostic(pth string, diagnostic protocol.Diagnostic, source string) location := fmt.Sprintf("%s:%d:%d", pth, diagnostic.Range.Start.Line+1, diagnostic.Range.Start.Character+1) - sourceInfo := "" + sourceInfo := source if diagnostic.Source != "" { - sourceInfo = diagnostic.Source - } else if source != "" { - sourceInfo = source + sourceInfo += " " + diagnostic.Source } codeInfo := "" diff --git a/internal/agent/tools/lsp_restart.go b/internal/agent/tools/lsp_restart.go new file mode 100644 index 0000000000000000000000000000000000000000..5e5a8a90a11927079086fe407384f32ceecf10c5 --- /dev/null +++ b/internal/agent/tools/lsp_restart.go @@ -0,0 +1,80 @@ +package tools + +import ( + "context" + _ "embed" + "fmt" + "log/slog" + "maps" + "strings" + "sync" + + "charm.land/fantasy" + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/lsp" +) + +const LSPRestartToolName = "lsp_restart" + +//go:embed lsp_restart.md +var lspRestartDescription []byte + +type LSPRestartParams struct { + // Name is the optional name of a specific LSP client to restart. + // If empty, all LSP clients will be restarted. + Name string `json:"name,omitempty"` +} + +func NewLSPRestartTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool { + return fantasy.NewAgentTool( + LSPRestartToolName, + string(lspRestartDescription), + func(ctx context.Context, params LSPRestartParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + if lspClients.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()) + } else { + client, exists := lspClients.Get(params.Name) + if !exists { + return fantasy.NewTextErrorResponse(fmt.Sprintf("LSP client '%s' not found", params.Name)), nil + } + clientsToRestart[params.Name] = client + } + + var restarted []string + var failed []string + var mu sync.Mutex + var wg sync.WaitGroup + for name, client := range clientsToRestart { + wg.Go(func() { + if err := client.Restart(); err != nil { + slog.Error("Failed to restart LSP client", "name", name, "error", err) + mu.Lock() + failed = append(failed, name) + mu.Unlock() + return + } + mu.Lock() + restarted = append(restarted, name) + mu.Unlock() + }) + } + + wg.Wait() + + var output string + if len(restarted) > 0 { + output = fmt.Sprintf("Successfully restarted %d LSP client(s): %s\n", len(restarted), strings.Join(restarted, ", ")) + } + if len(failed) > 0 { + output += fmt.Sprintf("Failed to restart %d LSP client(s): %s\n", len(failed), strings.Join(failed, ", ")) + return fantasy.NewTextErrorResponse(output), nil + } + + return fantasy.NewTextResponse(output), nil + }) +} diff --git a/internal/agent/tools/lsp_restart.md b/internal/agent/tools/lsp_restart.md new file mode 100644 index 0000000000000000000000000000000000000000..118ebd645391d9c73b01ff35a8a73094b6f766a3 --- /dev/null +++ b/internal/agent/tools/lsp_restart.md @@ -0,0 +1,25 @@ +Restart LSP (Language Server Protocol) clients. + + +- Restart all running LSP clients or a specific LSP client by name +- Useful when LSP servers become unresponsive or need to be reloaded +- Parameters: + - name (optional): Specific LSP client name to restart. If not provided, all clients will be restarted. + + + +- Gracefully shuts down all LSP clients +- Restarts them with their original configuration +- Reports success/failure for each client + + + +- Only restarts clients that were successfully started +- Does not modify LSP configurations +- Requires LSP clients to be already running + + + +- Use when LSP diagnostics are stale or unresponsive +- Call this tool if you notice LSP features not working properly + diff --git a/internal/config/config.go b/internal/config/config.go index f1dd94655a76bded8f5e9071b543fb09f8600e02..eb8394e11972de4c91017a4b92e59ccee804ef0c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -696,6 +696,7 @@ func allToolNames() []string { "multiedit", "lsp_diagnostics", "lsp_references", + "lsp_restart", "fetch", "agentic_fetch", "glob", diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 8924475ef9c652ea1962e4f032a0e62e560bce7a..08c888318724104935b9e92403f09f54f8ae20a4 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", "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"}, 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", "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"}, coderAgent.AllowedTools) taskAgent, ok := cfg.Agents[AgentTask] require.True(t, ok) diff --git a/internal/lsp/client.go b/internal/lsp/client.go index df28a30bbcce9504fb8a4a0eaba98a820028e705..d2f4ab8c6f1f495ec836198d86621a9df279457b 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -40,6 +40,10 @@ type Client struct { // Configuration for this LSP client config config.LSPConfig + // Original context and resolver for recreating the client + ctx context.Context + resolver config.VariableResolver + // Diagnostic change callback onDiagnosticsChanged func(name string, count int) @@ -59,58 +63,22 @@ type Client struct { } // New creates a new LSP client using the powernap implementation. -func New(ctx context.Context, name string, config config.LSPConfig, resolver config.VariableResolver) (*Client, error) { - // Convert working directory to file URI - workDir, err := os.Getwd() - if err != nil { - return nil, fmt.Errorf("failed to get working directory: %w", err) - } - - rootURI := string(protocol.URIFromPath(workDir)) - - command, err := resolver.ResolveValue(config.Command) - if err != nil { - return nil, fmt.Errorf("invalid lsp command: %w", err) - } - - // Create powernap client config - clientConfig := powernap.ClientConfig{ - Command: home.Long(command), - Args: config.Args, - RootURI: rootURI, - Environment: func() map[string]string { - env := make(map[string]string) - maps.Copy(env, config.Env) - return env - }(), - Settings: config.Options, - InitOptions: config.InitOptions, - WorkspaceFolders: []protocol.WorkspaceFolder{ - { - URI: rootURI, - Name: filepath.Base(workDir), - }, - }, - } - - // Create the powernap client - powernapClient, err := powernap.NewClient(clientConfig) - if err != nil { - return nil, fmt.Errorf("failed to create lsp client: %w", err) - } - +func New(ctx context.Context, name string, cfg config.LSPConfig, resolver config.VariableResolver) (*Client, error) { client := &Client{ - client: powernapClient, name: name, - fileTypes: config.FileTypes, + fileTypes: cfg.FileTypes, diagnostics: csync.NewVersionedMap[protocol.DocumentURI, []protocol.Diagnostic](), openFiles: csync.NewMap[string, *OpenFileInfo](), - config: config, + config: cfg, + ctx: ctx, + resolver: resolver, } - - // Initialize server state client.serverState.Store(StateStarting) + if err := client.createPowernapClient(); err != nil { + return nil, err + } + return client, nil } @@ -140,13 +108,7 @@ func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol Capabilities: protocolCaps, } - c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit) - c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration) - c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability) - c.RegisterNotificationHandler("window/showMessage", HandleServerMessage) - c.RegisterNotificationHandler("textDocument/publishDiagnostics", func(_ context.Context, _ string, params json.RawMessage) { - HandleDiagnostics(c, params) - }) + c.registerHandlers() return result, nil } @@ -163,6 +125,102 @@ func (c *Client) Close(ctx context.Context) error { return c.client.Exit() } +// 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)) + + command, err := c.resolver.ResolveValue(c.config.Command) + if err != nil { + return fmt.Errorf("invalid lsp command: %w", err) + } + + clientConfig := powernap.ClientConfig{ + Command: home.Long(command), + Args: c.config.Args, + RootURI: rootURI, + Environment: maps.Clone(c.config.Env), + Settings: c.config.Options, + InitOptions: c.config.InitOptions, + WorkspaceFolders: []protocol.WorkspaceFolder{ + { + URI: rootURI, + Name: filepath.Base(workDir), + }, + }, + } + + powernapClient, err := powernap.NewClient(clientConfig) + if err != nil { + return fmt.Errorf("failed to create lsp client: %w", err) + } + + c.client = powernapClient + return nil +} + +// registerHandlers registers the standard LSP notification and request handlers. +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("textDocument/publishDiagnostics", func(_ context.Context, _ string, params json.RawMessage) { + HandleDiagnostics(c, params) + }) +} + +// Restart closes the current LSP client and creates a new one with the same configuration. +func (c *Client) Restart() error { + var openFiles []string + for uri := range c.openFiles.Seq2() { + openFiles = append(openFiles, string(uri)) + } + + closeCtx, cancel := context.WithTimeout(c.ctx, 10*time.Second) + defer cancel() + + if err := c.Close(closeCtx); err != nil { + slog.Warn("Error closing client during restart", "name", c.name, "error", err) + } + + c.diagCountsCache = DiagnosticCounts{} + c.diagCountsVersion = 0 + + if err := c.createPowernapClient(); err != nil { + return err + } + + initCtx, cancel := context.WithTimeout(c.ctx, 30*time.Second) + defer cancel() + + c.SetServerState(StateStarting) + + if err := c.client.Initialize(initCtx, false); err != nil { + c.SetServerState(StateError) + return fmt.Errorf("failed to initialize lsp client: %w", err) + } + + c.registerHandlers() + + if err := c.WaitForServerReady(initCtx); err != nil { + slog.Error("Server failed to become ready after restart", "name", c.name, "error", err) + c.SetServerState(StateError) + return err + } + + for _, uri := range openFiles { + if err := c.OpenFile(initCtx, uri); err != nil { + slog.Warn("Failed to reopen file after restart", "file", uri, "error", err) + } + } + return nil +} + // ServerState represents the state of an LSP server type ServerState int diff --git a/internal/ui/chat/lsp_restart.go b/internal/ui/chat/lsp_restart.go new file mode 100644 index 0000000000000000000000000000000000000000..66c316fcaf7c949711babeb9ebe864e558ae5bc0 --- /dev/null +++ b/internal/ui/chat/lsp_restart.go @@ -0,0 +1,62 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// LSPRestartToolMessageItem is a message item that represents a lsprestart tool call. +type LSPRestartToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*LSPRestartToolMessageItem)(nil) + +// NewLSPRestartToolMessageItem creates a new [LSPRestartToolMessageItem]. +func NewLSPRestartToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &LSPRestartToolRenderContext{}, canceled) +} + +// LSPRestartToolRenderContext renders lsprestart tool messages. +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) + } + + var params tools.LSPRestartParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + toolParams := []string{} + if params.Name != "" { + toolParams = append(toolParams, params.Name) + } + + header := toolHeader(sty, opts.Status, "Restart LSP", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + 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/tools.go b/internal/ui/chat/tools.go index 1c9f71eb41168d913f4193af896ecb1b389136e7..ffe8e680159dc7c5a5ee177e06f7aa678a945641 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -244,6 +244,8 @@ func NewToolMessageItem( item = NewTodosToolMessageItem(sty, toolCall, result, canceled) case tools.ReferencesToolName: item = NewReferencesToolMessageItem(sty, toolCall, result, canceled) + case tools.LSPRestartToolName: + item = NewLSPRestartToolMessageItem(sty, toolCall, result, canceled) default: if strings.HasPrefix(toolCall.Name, "mcp_") { item = NewMCPToolMessageItem(sty, toolCall, result, canceled) From b23f1d7245113b5ca5ada5f3aed341eca2b63442 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:15:23 -0300 Subject: [PATCH 209/335] chore(legal): @AnyCPU 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 533ee23bab1bc33b34c5eaa189dd973b3c355046..5fe202d389a4520b38c2d0797e77aa89146d689a 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1103,6 +1103,14 @@ "created_at": "2026-01-23T04:00:52Z", "repoId": 987670088, "pullRequestNo": 1951 + }, + { + "name": "AnyCPU", + "id": 2059051, + "comment_id": 3791901071, + "created_at": "2026-01-23T19:14:55Z", + "repoId": 987670088, + "pullRequestNo": 1967 } ] } \ No newline at end of file From 8a37a3405a2461305b8300ba5deceb9bb2c753c8 Mon Sep 17 00:00:00 2001 From: M1xA Date: Fri, 23 Jan 2026 21:59:39 +0200 Subject: [PATCH 210/335] fix(ui): prevent AAAA probe bleed in terminals without Kitty graphics support (#1967) * fix(ui): prevent AAAA probe bleed in terminals without Kitty graphics support * refactor(ui): add an OS vendor type for Apple and use DRY for Kitty terminals * refactor(ui): do not export private symbols, fix a LookupEnv for mocks * refactor(ui): typo * refactor(ui): remove dead code * fix(ui): unify capability querying logic for terminal version and image capabilities --------- Co-authored-by: Ayman Bagabas --- internal/cmd/root.go | 17 ++-- internal/cmd/root_test.go | 160 ++++++++++++++++++++++++++++++++++++++ internal/ui/model/ui.go | 17 ++-- 3 files changed, 181 insertions(+), 13 deletions(-) create mode 100644 internal/cmd/root_test.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 4146244553b76ba4c0c2967636d7a077b706ee0d..c08bd839dda7db5fa00cf46cc7a2dde61924d819 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -33,6 +33,9 @@ import ( "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") @@ -94,11 +97,11 @@ crush -y slog.Info("New UI in control!") com := common.DefaultCommon(app) ui := ui.New(com) - ui.QueryVersion = shouldQueryTerminalVersion(env) + ui.QueryCapabilities = shouldQueryCapabilities(env) model = ui } else { ui := tui.New(app) - ui.QueryVersion = shouldQueryTerminalVersion(env) + ui.QueryVersion = shouldQueryCapabilities(env) model = ui } program := tea.NewProgram( @@ -299,12 +302,16 @@ func createDotCrushDir(dir string) error { return nil } -func shouldQueryTerminalVersion(env uv.Environ) bool { +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, "Apple") && !okSSHTTY) || + (!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) || // Terminals that do support XTVERSION. - stringext.ContainsAny(termType, "alacritty", "ghostty", "kitty", "rio", "wezterm") + stringext.ContainsAny(termType, kittyTerminals...) } diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2b6ca86c50dfeba036574242726c269e14617442 --- /dev/null +++ b/internal/cmd/root_test.go @@ -0,0 +1,160 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/charmbracelet/crush/internal/stringext" + uv "github.com/charmbracelet/ultraviolet" + "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 := stringext.ContainsAny(tt.s, tt.substr...) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 8bdb9bbf5dc34653597d3d6d8ad0a92b1cfdba7c..8c02191b34f413c566575138bc3e1ead9bae90c4 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -145,9 +145,9 @@ type UI struct { // terminal. sendProgressBar bool - // QueryVersion instructs the TUI to query for the terminal version when it + // QueryCapabilities instructs the TUI to query for the terminal version when it // starts. - QueryVersion bool + QueryCapabilities bool // Editor components textarea textarea.Model @@ -300,7 +300,7 @@ func New(com *common.Common) *UI { // Init initializes the UI model. func (m *UI) Init() tea.Cmd { var cmds []tea.Cmd - if m.QueryVersion { + if m.QueryCapabilities { cmds = append(cmds, tea.RequestTerminalVersion) } // load the user commands async @@ -351,11 +351,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } m.imgCaps.Env = uv.Environ(msg) - // XXX: Right now, we're using the same logic to determine image - // support. Terminals like Apple Terminal and possibly others might - // bleed characters when querying for Kitty graphics via APC escape - // sequences. - cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env)) + // Only query for image capabilities if the terminal is known to + // support Kitty graphics protocol. This prevents character bleeding + // on terminals that don't understand the APC escape sequences. + if m.QueryCapabilities { + cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env)) + } case loadSessionMsg: m.state = uiChat if m.forceCompactMode { From 88d10d13367eb0722898196b78a422cf98b6c21e Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 23 Jan 2026 15:06:38 -0500 Subject: [PATCH 211/335] feat(ui): add keybinding to copy chat message content to clipboard (#1947) * feat(ui): add keybinding to copy chat message content to clipboard This commit backports the ability to copy the content of chat messages (assistant, user, and tool messages) to the clipboard using the 'c' key when the message is focused. * feat(ui): format tool calls and results for clipboard copying --- internal/ui/chat/assistant.go | 9 + internal/ui/chat/messages.go | 5 + internal/ui/chat/tools.go | 595 ++++++++++++++++++++++++++++++++++ internal/ui/chat/user.go | 10 + internal/ui/common/common.go | 25 ++ internal/ui/model/chat.go | 10 + internal/ui/model/ui.go | 17 +- 7 files changed, 662 insertions(+), 9 deletions(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 7ff53264ead1b2e264cec981ef6aa5cb541247d3..66459a86fd1b457907d25ee0dbd36c69b26dbd34 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -255,3 +255,12 @@ func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) } return false } + +// HandleKeyEvent implements KeyEventHandler. +func (a *AssistantMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) { + if key.String() == "c" { + text := a.message.Content().Text + return true, common.CopyToClipboard(text, "Message copied to clipboard") + } + return false, nil +} diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 6be07e4759020c9aed25f042668d89e96584cccc..45314347187e7018445d30753ffd05d24dbc716a 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -41,6 +41,11 @@ type Expandable interface { ToggleExpanded() } +// KeyEventHandler is an interface for items that can handle key events. +type KeyEventHandler interface { + HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) +} + // MessageItem represents a [message.Message] item that can be displayed in the // UI and be part of a [list.List] identifiable by a unique ID. type MessageItem interface { diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index ffe8e680159dc7c5a5ee177e06f7aa678a945641..a91ca9b28355674a6aaf433d33b83ad838c8d446 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -1,14 +1,19 @@ package chat import ( + "encoding/json" "fmt" + "path/filepath" "strings" + "time" tea "charm.land/bubbletea/v2" "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/diff" + "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/common" @@ -413,6 +418,15 @@ func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) b return true } +// HandleKeyEvent implements KeyEventHandler. +func (t *baseToolMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) { + if key.String() == "c" { + text := t.formatToolForCopy() + return true, common.CopyToClipboard(text, "Tool content copied to clipboard") + } + return false, nil +} + // pendingTool renders a tool that is still in progress with an animation. func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string { icon := sty.Tool.IconPending.Render() @@ -807,3 +821,584 @@ func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, ex return sty.Tool.Body.Render(strings.Join(out, "\n")) } + +// formatToolForCopy formats the tool call for clipboard copying. +func (t *baseToolMessageItem) formatToolForCopy() string { + var parts []string + + toolName := prettifyToolName(t.toolCall.Name) + parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName)) + + if t.toolCall.Input != "" { + params := t.formatParametersForCopy() + if params != "" { + parts = append(parts, "### Parameters:") + parts = append(parts, params) + } + } + + if t.result != nil && t.result.ToolCallID != "" { + if t.result.IsError { + parts = append(parts, "### Error:") + parts = append(parts, t.result.Content) + } else { + parts = append(parts, "### Result:") + content := t.formatResultForCopy() + if content != "" { + parts = append(parts, content) + } + } + } else if t.status == ToolStatusCanceled { + parts = append(parts, "### Status:") + parts = append(parts, "Cancelled") + } else { + parts = append(parts, "### Status:") + parts = append(parts, "Pending...") + } + + return strings.Join(parts, "\n\n") +} + +// formatParametersForCopy formats tool parameters for clipboard copying. +func (t *baseToolMessageItem) formatParametersForCopy() string { + switch t.toolCall.Name { + case tools.BashToolName: + var params tools.BashParams + if json.Unmarshal([]byte(t.toolCall.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(t.toolCall.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(t.toolCall.Input), ¶ms) == nil { + return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) + } + case tools.MultiEditToolName: + var params tools.MultiEditParams + if json.Unmarshal([]byte(t.toolCall.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(t.toolCall.Input), ¶ms) == nil { + return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) + } + case tools.FetchToolName: + var params tools.FetchParams + if json.Unmarshal([]byte(t.toolCall.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(t.toolCall.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(t.toolCall.Input), ¶ms) == nil { + return fmt.Sprintf("**URL:** %s", params.URL) + } + case tools.GrepToolName: + var params tools.GrepParams + if json.Unmarshal([]byte(t.toolCall.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(t.toolCall.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(t.toolCall.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(t.toolCall.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(t.toolCall.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(t.toolCall.Input), ¶ms) == nil { + return fmt.Sprintf("**Task:**\n%s", params.Prompt) + } + } + + var params map[string]any + if json.Unmarshal([]byte(t.toolCall.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 "" +} + +// formatResultForCopy formats tool results for clipboard copying. +func (t *baseToolMessageItem) formatResultForCopy() string { + if t.result == nil { + return "" + } + + if t.result.Data != "" { + if strings.HasPrefix(t.result.MIMEType, "image/") { + return fmt.Sprintf("[Image: %s]", t.result.MIMEType) + } + return fmt.Sprintf("[Media: %s]", t.result.MIMEType) + } + + switch t.toolCall.Name { + case tools.BashToolName: + return t.formatBashResultForCopy() + case tools.ViewToolName: + return t.formatViewResultForCopy() + case tools.EditToolName: + return t.formatEditResultForCopy() + case tools.MultiEditToolName: + return t.formatMultiEditResultForCopy() + case tools.WriteToolName: + return t.formatWriteResultForCopy() + case tools.FetchToolName: + return t.formatFetchResultForCopy() + case tools.AgenticFetchToolName: + return t.formatAgenticFetchResultForCopy() + case tools.WebFetchToolName: + return t.formatWebFetchResultForCopy() + case agent.AgentToolName: + return t.formatAgentResultForCopy() + case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName: + return fmt.Sprintf("```\n%s\n```", t.result.Content) + default: + return t.result.Content + } +} + +// formatBashResultForCopy formats bash tool results for clipboard. +func (t *baseToolMessageItem) formatBashResultForCopy() string { + if t.result == nil { + return "" + } + + var meta tools.BashResponseMetadata + if t.result.Metadata != "" { + json.Unmarshal([]byte(t.result.Metadata), &meta) + } + + output := meta.Output + if output == "" && t.result.Content != tools.BashNoOutput { + output = t.result.Content + } + + if output == "" { + return "" + } + + return fmt.Sprintf("```bash\n%s\n```", output) +} + +// formatViewResultForCopy formats view tool results for clipboard. +func (t *baseToolMessageItem) formatViewResultForCopy() string { + if t.result == nil { + return "" + } + + var meta tools.ViewResponseMetadata + if t.result.Metadata != "" { + json.Unmarshal([]byte(t.result.Metadata), &meta) + } + + if meta.Content == "" { + return t.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() +} + +// formatEditResultForCopy formats edit tool results for clipboard. +func (t *baseToolMessageItem) formatEditResultForCopy() string { + if t.result == nil || t.result.Metadata == "" { + if t.result != nil { + return t.result.Content + } + return "" + } + + var meta tools.EditResponseMetadata + if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil { + return t.result.Content + } + + var params tools.EditParams + json.Unmarshal([]byte(t.toolCall.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() +} + +// formatMultiEditResultForCopy formats multi-edit tool results for clipboard. +func (t *baseToolMessageItem) formatMultiEditResultForCopy() string { + if t.result == nil || t.result.Metadata == "" { + if t.result != nil { + return t.result.Content + } + return "" + } + + var meta tools.MultiEditResponseMetadata + if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil { + return t.result.Content + } + + var params tools.MultiEditParams + json.Unmarshal([]byte(t.toolCall.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() +} + +// formatWriteResultForCopy formats write tool results for clipboard. +func (t *baseToolMessageItem) formatWriteResultForCopy() string { + if t.result == nil { + return "" + } + + var params tools.WriteParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil { + return t.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() +} + +// formatFetchResultForCopy formats fetch tool results for clipboard. +func (t *baseToolMessageItem) formatFetchResultForCopy() string { + if t.result == nil { + return "" + } + + var params tools.FetchParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil { + return t.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(t.result.Content) + + return result.String() +} + +// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard. +func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string { + if t.result == nil { + return "" + } + + var params tools.AgenticFetchParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil { + return t.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(t.result.Content) + result.WriteString("\n```") + + return result.String() +} + +// formatWebFetchResultForCopy formats web fetch tool results for clipboard. +func (t *baseToolMessageItem) formatWebFetchResultForCopy() string { + if t.result == nil { + return "" + } + + var params tools.WebFetchParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil { + return t.result.Content + } + + var result strings.Builder + result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL)) + result.WriteString("```markdown\n") + result.WriteString(t.result.Content) + result.WriteString("\n```") + + return result.String() +} + +// formatAgentResultForCopy formats agent tool results for clipboard. +func (t *baseToolMessageItem) formatAgentResultForCopy() string { + if t.result == nil { + return "" + } + + var result strings.Builder + + if t.result.Content != "" { + result.WriteString(fmt.Sprintf("```markdown\n%s\n```", t.result.Content)) + } + + return result.String() +} + +// prettifyToolName returns a human-readable name for tool names. +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 + } +} diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go index 7383c841ae3e274bdfea8dcc4db37e1259dbbb21..3482723bfdff519afeacb6bf7a553009c42cd64f 100644 --- a/internal/ui/chat/user.go +++ b/internal/ui/chat/user.go @@ -3,6 +3,7 @@ package chat import ( "strings" + tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/attachments" @@ -92,3 +93,12 @@ func (m *UserMessageItem) renderAttachments(width int) string { } return m.attachments.Render(attachments, false, width) } + +// HandleKeyEvent implements KeyEventHandler. +func (m *UserMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) { + if key.String() == "c" { + text := m.message.Content().Text + return true, common.CopyToClipboard(text, "Message copied to clipboard") + } + return false, nil +} diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 21ab903c388adaa1f626bef46f09c3829f927086..0150b6e4b84085637178009ec7859ae2f28aaf93 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -5,9 +5,12 @@ import ( "image" "os" + tea "charm.land/bubbletea/v2" + "github.com/atotto/clipboard" "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" uv "github.com/charmbracelet/ultraviolet" ) @@ -63,3 +66,25 @@ func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) { return false, nil } + +// CopyToClipboard copies the given text to the clipboard using both OSC 52 +// (terminal escape sequence) and native clipboard for maximum compatibility. +// Returns a command that reports success to the user with the given message. +func CopyToClipboard(text, successMessage string) tea.Cmd { + return CopyToClipboardWithCallback(text, successMessage, nil) +} + +// CopyToClipboardWithCallback copies text to clipboard and executes a callback +// before showing the success message. +// This is useful when you need to perform additional actions like clearing UI state. +func CopyToClipboardWithCallback(text, successMessage string, callback tea.Cmd) tea.Cmd { + return tea.Sequence( + tea.SetClipboard(text), + func() tea.Msg { + _ = clipboard.WriteAll(text) + return nil + }, + callback, + uiutil.ReportInfo(successMessage), + ) +} diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index f3388085d1808d984ed4b90bdeaeb58d71cb2463..d009a261580eaed209c1fc15966f50f4a8b3e62d 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -411,6 +411,16 @@ func (m *Chat) ToggleExpandedSelectedItem() { } } +// HandleKeyMsg handles key events for the chat component. +func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) { + if m.list.Focused() { + if handler, ok := m.list.SelectedItem().(chat.KeyEventHandler); ok { + return handler.HandleKeyEvent(key) + } + } + return false, nil +} + // HandleMouseDown handles mouse down events for the chat component. func (m *Chat) HandleMouseDown(x, y int) bool { if m.list.Len() == 0 { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 8c02191b34f413c566575138bc3e1ead9bae90c4..668b487567f701c8729a717c147ce39011b3edb5 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -23,7 +23,6 @@ import ( "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/atotto/clipboard" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/app" @@ -1619,7 +1618,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } m.chat.SelectLast() default: - handleGlobalKeys(msg) + if ok, cmd := m.chat.HandleKeyMsg(msg); ok { + cmds = append(cmds, cmd) + } else { + handleGlobalKeys(msg) + } } default: handleGlobalKeys(msg) @@ -2918,17 +2921,13 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string func (m *UI) copyChatHighlight() tea.Cmd { text := m.chat.HighlightContent() - return tea.Sequence( - tea.SetClipboard(text), - func() tea.Msg { - _ = clipboard.WriteAll(text) - return nil - }, + return common.CopyToClipboardWithCallback( + text, + "Selected text copied to clipboard", func() tea.Msg { m.chat.ClearMouse() return nil }, - uiutil.ReportInfo("Selected text copied to clipboard"), ) } From 5454a2ed119bd5baad60853665c7315aa97255b5 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 20 Jan 2026 14:35:41 -0300 Subject: [PATCH 212/335] refactor: rename `uiConfigure` to `uiOnboarding` --- internal/ui/model/ui.go | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 668b487567f701c8729a717c147ce39011b3edb5..a224179689594cc6c5fd390deded65d93e57f0fe 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -77,7 +77,7 @@ type uiState uint8 // Possible uiState values. const ( - uiConfigure uiState = iota + uiOnboarding uiState = iota uiInitialize uiLanding uiChat @@ -258,7 +258,7 @@ func New(com *common.Common) *UI { dialog: dialog.NewOverlay(), keyMap: keyMap, focus: uiFocusNone, - state: uiConfigure, + state: uiOnboarding, textarea: ta, chat: ch, completions: comp, @@ -273,13 +273,10 @@ func New(com *common.Common) *UI { // set onboarding state defaults ui.onboarding.yesInitializeSelected = true - // If no provider is configured show the user the provider list if !com.Config().IsConfigured() { - ui.state = uiConfigure - // if the project needs initialization show the user the question + ui.state = uiOnboarding } else if n, _ := config.ProjectNeedsInitialization(); n { ui.state = uiInitialize - // otherwise go to the landing UI } else { ui.state = uiLanding ui.focus = uiFocusEditor @@ -1392,7 +1389,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } switch m.state { - case uiConfigure: + case uiOnboarding: return tea.Batch(cmds...) case uiInitialize: cmds = append(cmds, m.updateInitializeView(msg)...) @@ -1647,14 +1644,14 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { screen.Clear(scr) switch m.state { - case uiConfigure: + case uiOnboarding: header := uv.NewStyledString(m.header) header.Draw(scr, layout.header) mainView := lipgloss.NewStyle().Width(layout.main.Dx()). Height(layout.main.Dy()). Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Configure ") + Render(" Onboarding ") main := uv.NewStyledString(mainView) main.Draw(scr, layout.main) @@ -2052,7 +2049,7 @@ func (m *UI) updateSize() { // Handle different app states switch m.state { - case uiConfigure, uiInitialize, uiLanding: + case uiOnboarding, uiInitialize, uiLanding: m.renderHeader(false, m.layout.header.Dx()) case uiChat: @@ -2094,7 +2091,7 @@ func (m *UI) generateLayout(w, h int) layout { appRect.Min.X += 1 appRect.Max.X -= 1 - if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) { + if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) { // extra padding on left and right for these states appRect.Min.X += 1 appRect.Max.X -= 1 @@ -2107,7 +2104,7 @@ func (m *UI) generateLayout(w, h int) layout { // Handle different app states switch m.state { - case uiConfigure, uiInitialize: + case uiOnboarding, uiInitialize: // Layout // // header From 1073723a7e7d2f5c16ec7fd15b05974fc7293bf2 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 20 Jan 2026 16:44:32 -0300 Subject: [PATCH 213/335] fix(list): prevent panic due to negative index --- internal/ui/list/list.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 6b9fbf45b0dfcdc310a014b42eb04950aa891a71..a731a0a30023c451f2e1067e4e15ccb5e06ea177 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -308,6 +308,8 @@ func (l *List) Render() string { currentOffset = 0 // Reset offset for subsequent items } + l.height = max(l.height, 0) + if len(lines) > l.height { lines = lines[:l.height] } From 44e1ca7c4a0c7d008b79a34521360a1d11d16883 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 20 Jan 2026 16:44:55 -0300 Subject: [PATCH 214/335] fix(dialogs): prevent panic due to negative index --- internal/ui/dialog/api_key_input.go | 2 +- internal/ui/dialog/commands.go | 2 +- internal/ui/dialog/models.go | 2 +- internal/ui/dialog/sessions.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index 430f7b4629294faa83bad9b5b90ca363ceb6f1b7..72ff9739a76394c529ee954392e8d9990c70a8a5 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -71,7 +71,7 @@ func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config. m.input.Placeholder = "Enter you API key..." m.input.SetStyles(com.Styles.TextInput) m.input.Focus() - m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + m.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding m.spinner = spinner.New( spinner.WithSpinner(spinner.Dot), diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index b5058e50f9c5bae17acd76994115597e59ee912c..0bf8b52d04cba248b3b19e412d981d92b4ab5a08 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -256,7 +256,7 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t.Dialog.HelpView.GetVerticalFrameSize() + t.Dialog.View.GetVerticalFrameSize() - c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + c.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding c.list.SetSize(innerWidth, height-heightOffset) c.help.SetWidth(innerWidth) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index ed42d388fde959c41017217fc279fc175a9e0f49..00677124c2f02f9cfc7bf7a9121c09464fbf90dd 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -251,7 +251,7 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + t.Dialog.HelpView.GetVerticalFrameSize() + t.Dialog.View.GetVerticalFrameSize() - m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + m.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding m.list.SetSize(innerWidth, height-heightOffset) m.help.SetWidth(innerWidth) diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 9f7023a44e005c4d6ab735206824519e5b91e5bf..2ace3c4634a2980d1b3e82afc947a9f21b9a5541 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -148,7 +148,7 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + t.Dialog.HelpView.GetVerticalFrameSize() + t.Dialog.View.GetVerticalFrameSize() - s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + s.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding s.list.SetSize(innerWidth, height-heightOffset) s.help.SetWidth(innerWidth) From 1e691e0c49c343e0df46bc332f5d973b4cd85b7f Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 23 Jan 2026 14:50:52 -0300 Subject: [PATCH 215/335] fix: ensure hyper is the first provider in the list --- internal/tui/components/dialogs/models/list.go | 6 ++---- internal/ui/dialog/models.go | 13 +++++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index 9640b894d8e5bfb8659440f18f4cf04fb413bf02..581122525a89dd308bb57a30e6b15a4cd0896708 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -186,9 +186,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { // Move "Charm Hyper" to first position // (but still after recent models and custom providers). - sortedProviders := make([]catwalk.Provider, len(m.providers)) - copy(sortedProviders, m.providers) - slices.SortStableFunc(sortedProviders, func(a, b catwalk.Provider) int { + slices.SortStableFunc(m.providers, func(a, b catwalk.Provider) int { switch { case a.ID == "hyper": return -1 @@ -200,7 +198,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { }) // Then add the known providers from the predefined list - for _, provider := range sortedProviders { + for _, provider := range m.providers { // Skip if we already added this provider as an unknown provider if addedProviders[string(provider.ID)] { continue diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 00677124c2f02f9cfc7bf7a9121c09464fbf90dd..532fad84acd816d67e88e71eb8160964e70e35b0 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -354,6 +354,19 @@ func (m *Models) setProviderItems() error { } } + // 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 + } + }) + // Now add known providers from the predefined list for _, provider := range m.providers { providerID := string(provider.ID) From 28e0ff355198c3858f45fe87fea3d389f9a47b8e Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 20 Jan 2026 18:05:11 -0300 Subject: [PATCH 216/335] feat: implement onboarding flow on the new ui codebase --- internal/app/app.go | 5 ++- internal/ui/common/common.go | 10 +++++ internal/ui/dialog/api_key_input.go | 53 +++++++++++++++++------- internal/ui/dialog/common.go | 11 ++++- internal/ui/dialog/dialog.go | 18 ++++++++- internal/ui/dialog/models.go | 58 ++++++++++++++++++++++----- internal/ui/dialog/oauth.go | 29 +++++++++++--- internal/ui/dialog/oauth_copilot.go | 10 ++++- internal/ui/dialog/oauth_hyper.go | 10 ++++- internal/ui/model/sidebar.go | 5 ++- internal/ui/model/status.go | 20 +++++++--- internal/ui/model/ui.go | 62 +++++++++++++++++++++++------ 12 files changed, 233 insertions(+), 58 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 816f7a8b25cc945436a148110ce7d774750bb493..b6cb9c8dfb95d79eccec07145f1246e6b8910713 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -351,15 +351,16 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, case largeModel != "": // No small model specified, but large model was - use provider's default. - smallCfg := app.getDefaultSmallModel(largeProviderID) + smallCfg := app.GetDefaultSmallModel(largeProviderID) app.config.Models[config.SelectedModelTypeSmall] = smallCfg } return app.AgentCoordinator.UpdateModels(ctx) } +// GetDefaultSmallModel returns the default small model for the given // provider. Falls back to the large model if no default is found. -func (app *App) getDefaultSmallModel(providerID string) config.SelectedModel { +func (app *App) GetDefaultSmallModel(providerID string) config.SelectedModel { cfg := app.config largeModelCfg := cfg.Models[config.SelectedModelTypeLarge] diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 0150b6e4b84085637178009ec7859ae2f28aaf93..0c811b0384b0b1bf24b227b6500e8c9a21726d21 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -52,6 +52,16 @@ func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle { return image.Rect(minX, minY, maxX, maxY) } +// BottomLeftRect returns a new [Rectangle] positioned at the bottom-left within the given area with the +// specified width and height. +func BottomLeftRect(area uv.Rectangle, width, height int) uv.Rectangle { + minX := area.Min.X + maxX := minX + width + maxY := area.Max.Y + minY := maxY - height + return image.Rect(minX, minY, maxX, maxY) +} + // IsFileTooBig checks if the file at the given path exceeds the specified size // limit. func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) { diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index 72ff9739a76394c529ee954392e8d9990c70a8a5..01d5e41a1d9d7e4a3fa25db91caa12fb12daea1f 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -33,7 +33,8 @@ const APIKeyInputID = "api_key_input" // APIKeyInput represents a model selection dialog. type APIKeyInput struct { - com *common.Common + com *common.Common + isOnboarding bool provider catwalk.Provider model config.SelectedModel @@ -54,11 +55,18 @@ type APIKeyInput struct { var _ Dialog = (*APIKeyInput)(nil) // NewAPIKeyInput creates a new Models dialog. -func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, tea.Cmd) { +func NewAPIKeyInput( + com *common.Common, + isOnboarding bool, + provider catwalk.Provider, + model config.SelectedModel, + modelType config.SelectedModelType, +) (*APIKeyInput, tea.Cmd) { t := com.Styles m := APIKeyInput{} m.com = com + m.isOnboarding = isOnboarding m.provider = provider m.model = model m.modelType = modelType @@ -170,28 +178,45 @@ func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { helpStyle.Render(m.help.View(m)), }, "\n") - view := dialogStyle.Render(content) - cur := m.Cursor() - DrawCenterCursor(scr, area, view, cur) + + if m.isOnboarding { + view := content + DrawOnboardingCursor(scr, area, view, cur) + + // FIXME(@andreynering): Figure it out how to properly fix this + if cur != nil { + cur.Y -= 1 + cur.X -= 1 + } + } else { + view := dialogStyle.Render(content) + DrawCenterCursor(scr, area, view, cur) + } return cur } func (m *APIKeyInput) headerView() string { - t := m.com.Styles - titleStyle := t.Dialog.Title - dialogStyle := t.Dialog.View.Width(m.width) - + var ( + t = m.com.Styles + titleStyle = t.Dialog.Title + textStyle = t.Dialog.PrimaryText + dialogStyle = t.Dialog.View.Width(m.width) + ) + if m.isOnboarding { + return textStyle.Render(m.dialogTitle()) + } headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset) } func (m *APIKeyInput) dialogTitle() string { - t := m.com.Styles - textStyle := t.Dialog.TitleText - errorStyle := t.Dialog.TitleError - accentStyle := t.Dialog.TitleAccent - + var ( + t = m.com.Styles + textStyle = t.Dialog.TitleText + errorStyle = t.Dialog.TitleError + accentStyle = t.Dialog.TitleAccent + ) switch m.state { case APIKeyInputStateInitial: return textStyle.Render("Enter your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(".") diff --git a/internal/ui/dialog/common.go b/internal/ui/dialog/common.go index 76b75064670935715f03e0d732b9df5070b9e9da..fe54a7e60649a222eabe80e2ecc02546036bac17 100644 --- a/internal/ui/dialog/common.go +++ b/internal/ui/dialog/common.go @@ -59,6 +59,10 @@ type RenderContext struct { // Help is the help view content. This will be appended to the content parts // slice using the default dialog help style. Help string + // IsOnboarding indicates whether to render the dialog as part of the + // onboarding flow. This means that the content will be rendered at the + // bottom left of the screen. + IsOnboarding bool } // NewRenderContext creates a new RenderContext with the provided styles and width. @@ -82,7 +86,8 @@ func (rc *RenderContext) Render() string { titleStyle := rc.Styles.Dialog.Title dialogStyle := rc.Styles.Dialog.View.Width(rc.Width) - parts := []string{} + var parts []string + if len(rc.Title) > 0 { var titleInfoWidth int if len(rc.TitleInfo) > 0 { @@ -125,6 +130,8 @@ func (rc *RenderContext) Render() string { } content := strings.Join(parts, "\n") - + if rc.IsOnboarding { + return content + } return dialogStyle.Render(content) } diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 7a3db40128fb1e5543a94a93faa4ae9aeec5f947..990b4ed68174bee20d627dec5f7176d9466b77d8 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -170,7 +170,6 @@ func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cu cur.X += center.Min.X cur.Y += center.Min.Y } - uv.NewStyledString(view).Draw(scr, center) } @@ -179,6 +178,23 @@ func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) { DrawCenterCursor(scr, area, view, nil) } +// DrawOnboarding draws the given string view centered in the screen area. +func DrawOnboarding(scr uv.Screen, area uv.Rectangle, view string) { + DrawOnboardingCursor(scr, area, view, nil) +} + +// DrawOnboardingCursor draws the given string view positioned at the bottom +// left area of the screen. +func DrawOnboardingCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) { + width, height := lipgloss.Size(view) + bottomLeft := common.BottomLeftRect(area, width, height) + if cur != nil { + cur.X += bottomLeft.Min.X + cur.Y += bottomLeft.Min.Y + } + uv.NewStyledString(view).Draw(scr, bottomLeft) +} + // Draw renders the overlay and its dialogs. func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { var cur *tea.Cursor diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 532fad84acd816d67e88e71eb8160964e70e35b0..450ee8b99b75f13c1c9885281a1dfd1a0a3d9867 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -62,8 +62,9 @@ func (mt ModelType) Placeholder() string { } const ( - largeModelInputPlaceholder = "Choose a model for large, complex tasks" - smallModelInputPlaceholder = "Choose a model for small, simple tasks" + onboardingModelInputPlaceholder = "Find your fave" + largeModelInputPlaceholder = "Choose a model for large, complex tasks" + smallModelInputPlaceholder = "Choose a model for small, simple tasks" ) // ModelsID is the identifier for the model selection dialog. @@ -73,7 +74,8 @@ const defaultModelsDialogMaxWidth = 70 // Models represents a model selection dialog. type Models struct { - com *common.Common + com *common.Common + isOnboarding bool modelType ModelType providers []catwalk.Provider @@ -94,10 +96,12 @@ type Models struct { var _ Dialog = (*Models)(nil) // NewModels creates a new Models dialog. -func NewModels(com *common.Common) (*Models, error) { +func NewModels(com *common.Common, isOnboarding bool) (*Models, error) { t := com.Styles m := &Models{} m.com = com + m.isOnboarding = isOnboarding + help := help.New() help.Styles = t.DialogHelpStyles() @@ -108,7 +112,7 @@ func NewModels(com *common.Common) (*Models, error) { m.input = textinput.New() m.input.SetVirtualCursor(false) - m.input.Placeholder = largeModelInputPlaceholder + m.input.Placeholder = onboardingModelInputPlaceholder m.input.SetStyles(com.Styles.TextInput) m.input.Focus() @@ -194,6 +198,9 @@ func (m *Models) HandleMsg(msg tea.Msg) Action { ModelType: modelItem.SelectedModelType(), } case key.Matches(msg, m.keyMap.Tab): + if m.isOnboarding { + break + } if m.modelType == ModelTypeLarge { m.modelType = ModelTypeSmall } else { @@ -251,6 +258,7 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + t.Dialog.HelpView.GetVerticalFrameSize() + t.Dialog.View.GetVerticalFrameSize() + m.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding m.list.SetSize(innerWidth, height-heightOffset) m.help.SetWidth(innerWidth) @@ -258,21 +266,49 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { rc := NewRenderContext(t, width) rc.Title = "Switch Model" rc.TitleInfo = m.modelTypeRadioView() + + if m.isOnboarding { + titleText := t.Dialog.PrimaryText.Render("To start, let's choose a provider and model.") + rc.AddPart(titleText) + } + inputView := t.Dialog.InputPrompt.Render(m.input.View()) rc.AddPart(inputView) + listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render()) rc.AddPart(listView) - rc.Help = m.help.View(m) - view := rc.Render() + rc.Help = m.help.View(m) cur := m.Cursor() - DrawCenterCursor(scr, area, view, cur) + + if m.isOnboarding { + rc.Title = "" + rc.TitleInfo = "" + rc.IsOnboarding = true + view := rc.Render() + DrawOnboardingCursor(scr, area, view, cur) + + // FIXME(@andreynering): Figure it out how to properly fix this + if cur != nil { + cur.Y -= 1 + cur.X -= 1 + } + } else { + view := rc.Render() + DrawCenterCursor(scr, area, view, cur) + } return cur } // ShortHelp returns the short help view. func (m *Models) ShortHelp() []key.Binding { + if m.isOnboarding { + return []key.Binding{ + m.keyMap.UpDown, + m.keyMap.Select, + } + } return []key.Binding{ m.keyMap.UpDown, m.keyMap.Tab, @@ -459,10 +495,12 @@ func (m *Models) setProviderItems() error { // Set model groups in the list. m.list.SetGroups(groups...) m.list.SetSelectedItem(selectedItemID) - m.list.ScrollToSelected() + m.list.ScrollToTop() // Update placeholder based on model type - m.input.Placeholder = m.modelType.Placeholder() + if !m.isOnboarding { + m.input.Placeholder = m.modelType.Placeholder() + } return nil } diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index ae5a2ab25a1ec596ba50ea6b3a0d03f560f1b10d..e4c4ec664a893846f0b37fe2d4bfc323ac1773da 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -41,7 +41,8 @@ const OAuthID = "oauth" // OAuth handles the OAuth flow authentication. type OAuth struct { - com *common.Common + com *common.Common + isOnboarding bool provider catwalk.Provider model config.SelectedModel @@ -71,11 +72,19 @@ type OAuth struct { var _ Dialog = (*OAuth)(nil) // newOAuth creates a new device flow component. -func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, tea.Cmd) { +func newOAuth( + com *common.Common, + isOnboarding bool, + provider catwalk.Provider, + model config.SelectedModel, + modelType config.SelectedModelType, + oAuthProvider OAuthProvider, +) (*OAuth, tea.Cmd) { t := com.Styles m := OAuth{} m.com = com + m.isOnboarding = isOnboarding m.provider = provider m.model = model m.modelType = modelType @@ -175,9 +184,14 @@ func (m *OAuth) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { var ( t = m.com.Styles dialogStyle = t.Dialog.View.Width(m.width) - view = dialogStyle.Render(m.dialogContent()) ) - DrawCenterCursor(scr, area, view, nil) + if m.isOnboarding { + view := m.dialogContent() + DrawOnboarding(scr, area, view) + } else { + view := dialogStyle.Render(m.dialogContent()) + DrawCenter(scr, area, view) + } return nil } @@ -205,10 +219,15 @@ func (m *OAuth) headerContent() string { var ( t = m.com.Styles titleStyle = t.Dialog.Title + textStyle = t.Dialog.PrimaryText dialogStyle = t.Dialog.View.Width(m.width) headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() + dialogTitle = fmt.Sprintf("Authenticate with %s", m.oAuthProvider.name()) ) - return common.DialogTitle(t, titleStyle.Render("Authenticate with "+m.oAuthProvider.name()), m.width-headerOffset) + if m.isOnboarding { + return textStyle.Render(dialogTitle) + } + return common.DialogTitle(t, titleStyle.Render(dialogTitle), m.width-headerOffset) } func (m *OAuth) innerDialogContent() string { diff --git a/internal/ui/dialog/oauth_copilot.go b/internal/ui/dialog/oauth_copilot.go index 19e389b38a965c4c22ba1b2080b029975aaedc19..4b671852d476578f94653393796056d630ba23a5 100644 --- a/internal/ui/dialog/oauth_copilot.go +++ b/internal/ui/dialog/oauth_copilot.go @@ -12,8 +12,14 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" ) -func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) { - return newOAuth(com, provider, model, modelType, &OAuthCopilot{}) +func NewOAuthCopilot( + com *common.Common, + isOnboarding bool, + provider catwalk.Provider, + model config.SelectedModel, + modelType config.SelectedModelType, +) (*OAuth, tea.Cmd) { + return newOAuth(com, isOnboarding, provider, model, modelType, &OAuthCopilot{}) } type OAuthCopilot struct { diff --git a/internal/ui/dialog/oauth_hyper.go b/internal/ui/dialog/oauth_hyper.go index 478960b0df10f62d88b65450de360f4db6d6cd0c..bddf4d78ef2c920855f21e056e7ee48f985b0b68 100644 --- a/internal/ui/dialog/oauth_hyper.go +++ b/internal/ui/dialog/oauth_hyper.go @@ -12,8 +12,14 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" ) -func NewOAuthHyper(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) { - return newOAuth(com, provider, model, modelType, &OAuthHyper{}) +func NewOAuthHyper( + com *common.Common, + isOnboarding bool, + provider catwalk.Provider, + model config.SelectedModel, + modelType config.SelectedModelType, +) (*OAuth, tea.Cmd) { + return newOAuth(com, isOnboarding, provider, model, modelType, &OAuthHyper{}) } type OAuthHyper struct { diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 2437fab9177b9186cfcd4c185c45c48204cea7d9..a0623a1262863da749bbaebdfb2f42eccef7cf50 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -45,14 +45,15 @@ func (m *UI) modelInfo(width int) string { } var modelContext *common.ModelContextInfo - if m.session != nil { + if model != nil && m.session != nil { modelContext = &common.ModelContextInfo{ ContextUsed: m.session.CompletionTokens + m.session.PromptTokens, Cost: m.session.Cost, ModelContext: model.CatwalkCfg.ContextWindow, } + return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width) } - return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width) + return "" } // getDynamicHeightLimits will give us the num of items to show in each section based on the hight diff --git a/internal/ui/model/status.go b/internal/ui/model/status.go index a3371d27d2f19f3236734ea8a31602fa5d518e62..2e1b9396e32b970c663cb755e2dc74f6c9f5eca0 100644 --- a/internal/ui/model/status.go +++ b/internal/ui/model/status.go @@ -17,10 +17,11 @@ const DefaultStatusTTL = 5 * time.Second // Status is the status bar and help model. type Status struct { - com *common.Common - help help.Model - helpKm help.KeyMap - msg uiutil.InfoMsg + com *common.Common + hideHelp bool + help help.Model + helpKm help.KeyMap + msg uiutil.InfoMsg } // NewStatus creates a new status bar and help model. @@ -58,10 +59,17 @@ func (s *Status) ToggleHelp() { s.help.ShowAll = !s.help.ShowAll } +// SetHideHelp sets whether the app is on the onboarding flow. +func (s *Status) SetHideHelp(hideHelp bool) { + s.hideHelp = hideHelp +} + // Draw draws the status bar onto the screen. func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) { - helpView := s.com.Styles.Status.Help.Render(s.help.View(s.helpKm)) - uv.NewStyledString(helpView).Draw(scr, area) + if !s.hideHelp { + helpView := s.com.Styles.Status.Help.Render(s.help.View(s.helpKm)) + uv.NewStyledString(helpView).Draw(scr, area) + } // Render notifications if s.msg.IsEmpty() { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a224179689594cc6c5fd390deded65d93e57f0fe..ec17627a43fc70c945dd22cf5eee5082c5d5eac2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -299,6 +299,11 @@ func (m *UI) Init() tea.Cmd { if m.QueryCapabilities { cmds = append(cmds, tea.RequestTerminalVersion) } + if m.state == uiOnboarding { + if cmd := m.openModelsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + } // load the user commands async cmds = append(cmds, m.loadCustomCommands()) return tea.Batch(cmds...) @@ -1028,10 +1033,23 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { return tea.Batch(cmds...) } + isOnboarding := m.state == uiOnboarding + switch msg := action.(type) { // Generic dialog messages case dialog.ActionClose: + if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) { + break + } + m.dialog.CloseFrontDialog() + + if isOnboarding { + if cmd := m.openModelsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.focus == uiFocusEditor { cmds = append(cmds, m.textarea.Focus()) } @@ -1164,10 +1182,18 @@ 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)) + } 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, func() tea.Msg { - m.com.App.UpdateAgentModel(context.TODO()) + if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil { + return uiutil.ReportError(err) + } modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) @@ -1177,6 +1203,16 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { m.dialog.CloseDialog(dialog.APIKeyInputID) m.dialog.CloseDialog(dialog.OAuthID) m.dialog.CloseDialog(dialog.ModelsID) + + if isOnboarding { + m.state = uiLanding + m.focus = uiFocusEditor + + m.com.Config().SetupAgents() + if err := m.com.App.InitCoderAgent(context.TODO()); err != nil { + cmds = append(cmds, uiutil.ReportError(err)) + } + } case dialog.ActionSelectReasoningEffort: if m.isAgentBusy() { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) @@ -1284,15 +1320,17 @@ func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.Se var ( dlg dialog.Dialog cmd tea.Cmd + + isOnboarding = m.state == uiOnboarding ) switch provider.ID { case "hyper": - dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType) + dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType) case catwalk.InferenceProviderCopilot: - dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType) + dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType) default: - dlg, cmd = dialog.NewAPIKeyInput(m.com, provider, model, modelType) + dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType) } if m.dialog.ContainsDialog(dlg.ID()) { @@ -1648,12 +1686,8 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { header := uv.NewStyledString(m.header) header.Draw(scr, layout.header) - mainView := lipgloss.NewStyle().Width(layout.main.Dx()). - Height(layout.main.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Onboarding ") - main := uv.NewStyledString(mainView) - main.Draw(scr, layout.main) + // 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) @@ -1697,11 +1731,14 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { } } + isOnboarding := m.state == uiOnboarding + // Add status and help layer + m.status.SetHideHelp(isOnboarding) m.status.Draw(scr, layout.status) // Draw completions popup if open - if m.completionsOpen && m.completions.HasItems() { + if !isOnboarding && m.completionsOpen && m.completions.HasItems() { w, h := m.completions.Size() x := m.completionsPositionStart.X y := m.completionsPositionStart.Y - h @@ -2606,7 +2643,8 @@ func (m *UI) openModelsDialog() tea.Cmd { return nil } - modelsDialog, err := dialog.NewModels(m.com) + isOnboarding := m.state == uiOnboarding + modelsDialog, err := dialog.NewModels(m.com, isOnboarding) if err != nil { return uiutil.ReportError(err) } From 38ec868b94763e4837042b14d66e116d487dfae8 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 23 Jan 2026 16:31:53 -0500 Subject: [PATCH 218/335] chore: bump glamour to v2.0.0-20260123212943-6014aa153a9b Fixes: https://github.com/charmbracelet/crush/issues/1845 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d4f09c84d627ed5991e6b9db5aff2e41009a98c0..fd40f65510b9a35ea3b9b1b7e2f464eca9e50535 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/fantasy v0.6.1 - charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1 + 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 charm.land/x/vcr v0.1.1 diff --git a/go.sum b/go.sum index aade1be477d587b00e8512c02e8a1164d8937a85..cddd80bdbc09faed251039a667ade88c04136e03 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/fantasy v0.6.1 h1:v3pavSHpZ5xTw98TpNYoj6DRq4ksCBWwJiZeiG/mVIc= charm.land/fantasy v0.6.1/go.mod h1:Ifj41bNnIXJ1aF6sLKcS9y3MzWbDnObmcHrCaaHfpZ0= -charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1 h1:9q4+yyU7105T3OrOx0csMyKnw89yMSijJ+rVld/Z2ek= -charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8= +charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0= +charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 h1:xZFcNsJMiIDbFtWRyDmkKNk1sjojfaom4Zoe0cyH/8c= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971/go.mod h1:i61Y3FmdbcBNSKa+pKB3DaE4uVQmBLMs/xlvRyHcXAE= charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da h1:vZa/Ow0uLclpfaDY0ubjzE+B0eLQqi2zanmpeALanow= From a9c753817692cd26697227d353e051d7447424a1 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 23 Jan 2026 23:04:19 -0300 Subject: [PATCH 219/335] ci: fix snapshot goreleaser dist Signed-off-by: Carlos Alexandro Becker --- .github/workflows/snapshot.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index a033e082bfc1a109f222f2ae9d57731f7b34318d..ca5e3a78241d0b9be901d19e398b86efeb1715d2 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -28,6 +28,7 @@ jobs: - uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 with: version: "~> v2" + distribution: goreleaser-pro args: build --snapshot --clean env: GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} From d2f23b20e3ff6b3c8947f83e635d9785a2e3afde Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sat, 24 Jan 2026 00:07:02 -0300 Subject: [PATCH 220/335] chore(legal): @billycao 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 5fe202d389a4520b38c2d0797e77aa89146d689a..9ac85999ed39d86d5cf40703b1d04521e69b984e 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1111,6 +1111,14 @@ "created_at": "2026-01-23T19:14:55Z", "repoId": 987670088, "pullRequestNo": 1967 + }, + { + "name": "billycao", + "id": 543122, + "comment_id": 3793624061, + "created_at": "2026-01-24T03:06:50Z", + "repoId": 987670088, + "pullRequestNo": 1971 } ] } \ No newline at end of file From 3193895cad9eb15d49a72c21de9e7aa7ed92ac2e Mon Sep 17 00:00:00 2001 From: Billy Cao Date: Fri, 23 Jan 2026 20:59:33 -0800 Subject: [PATCH 221/335] docs(readme): add SYNTHETIC_API_KEY (#1971) --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f8028ed22b4e45080ac975282b29c8c11aa5cd97..96e83e33d563964e2ee591623faf95fc735ca666 100644 --- a/README.md +++ b/README.md @@ -190,12 +190,13 @@ That said, you can also set environment variables for preferred providers. | `HF_TOKEN` | Huggingface Inference | | `VERTEXAI_PROJECT` | Google Cloud VertexAI (Gemini) | | `VERTEXAI_LOCATION` | Google Cloud VertexAI (Gemini) | +| `SYNTHETIC_API_KEY` | Synthetic | | `GROQ_API_KEY` | Groq | -| `AWS_ACCESS_KEY_ID` | Amazon Bedrock (Claude) | -| `AWS_SECRET_ACCESS_KEY` | Amazon Bedrock (Claude) | -| `AWS_REGION` | Amazon Bedrock (Claude) | -| `AWS_PROFILE` | Amazon Bedrock (Custom Profile) | -| `AWS_BEARER_TOKEN_BEDROCK` | Amazon Bedrock | +| `AWS_ACCESS_KEY_ID` | Amazon Bedrock (Claude) | +| `AWS_SECRET_ACCESS_KEY` | Amazon Bedrock (Claude) | +| `AWS_REGION` | Amazon Bedrock (Claude) | +| `AWS_PROFILE` | Amazon Bedrock (Custom Profile) | +| `AWS_BEARER_TOKEN_BEDROCK` | Amazon Bedrock | | `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models | | `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) | | `AZURE_OPENAI_API_VERSION` | Azure OpenAI models | From 8e7a331c03a359c491eb93df354cb28da4293afb Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sat, 24 Jan 2026 00:10:06 -0500 Subject: [PATCH 222/335] docs(readme): add Z.ai API key info --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 96e83e33d563964e2ee591623faf95fc735ca666..cd68cb962de3518cce6f86ac3513d388bf9bfcd0 100644 --- a/README.md +++ b/README.md @@ -183,15 +183,16 @@ That said, you can also set environment variables for preferred providers. | --------------------------- | -------------------------------------------------- | | `ANTHROPIC_API_KEY` | Anthropic | | `OPENAI_API_KEY` | OpenAI | -| `OPENROUTER_API_KEY` | OpenRouter | | `VERCEL_API_KEY` | Vercel AI Gateway | | `GEMINI_API_KEY` | Google Gemini | +| `SYNTHETIC_API_KEY` | Synthetic | +| `ZAI_API_KEY` | Z.ai | +| `HF_TOKEN` | Hugging Face Inference | | `CEREBRAS_API_KEY` | Cerebras | -| `HF_TOKEN` | Huggingface Inference | +| `OPENROUTER_API_KEY` | OpenRouter | +| `GROQ_API_KEY` | Groq | | `VERTEXAI_PROJECT` | Google Cloud VertexAI (Gemini) | | `VERTEXAI_LOCATION` | Google Cloud VertexAI (Gemini) | -| `SYNTHETIC_API_KEY` | Synthetic | -| `GROQ_API_KEY` | Groq | | `AWS_ACCESS_KEY_ID` | Amazon Bedrock (Claude) | | `AWS_SECRET_ACCESS_KEY` | Amazon Bedrock (Claude) | | `AWS_REGION` | Amazon Bedrock (Claude) | From 77bfd1a708e19b58d3c6525c46dd0c2c3add3cf7 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sat, 24 Jan 2026 19:42:55 -0300 Subject: [PATCH 223/335] chore(legal): @gdamjan 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 9ac85999ed39d86d5cf40703b1d04521e69b984e..5b5e74252b831d49bdec16557311a8e39de71b16 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1119,6 +1119,14 @@ "created_at": "2026-01-24T03:06:50Z", "repoId": 987670088, "pullRequestNo": 1971 + }, + { + "name": "gdamjan", + "id": 81654, + "comment_id": 3795660594, + "created_at": "2026-01-24T22:42:46Z", + "repoId": 987670088, + "pullRequestNo": 1978 } ] } \ No newline at end of file From abbee92df2ec996ee3c86eecc4c32859de149532 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 26 Jan 2026 14:31:12 +0100 Subject: [PATCH 224/335] feat: delete sessions (#1963) * feat: delete sessions * chore: small cleanup * chore: make delete transactional * refactor: cleanup the delete logic --- internal/agent/common_test.go | 2 +- internal/app/app.go | 2 +- internal/session/session.go | 34 ++++- internal/ui/common/elements.go | 4 +- internal/ui/dialog/api_key_input.go | 2 +- internal/ui/dialog/arguments.go | 2 +- internal/ui/dialog/commands.go | 2 +- internal/ui/dialog/commands_item.go | 8 +- internal/ui/dialog/common.go | 25 +++- internal/ui/dialog/models_item.go | 8 +- internal/ui/dialog/oauth.go | 2 +- internal/ui/dialog/permissions.go | 2 +- internal/ui/dialog/reasoning.go | 8 +- internal/ui/dialog/sessions.go | 221 ++++++++++++++++++++++------ internal/ui/dialog/sessions_item.go | 44 ++++-- internal/ui/model/ui.go | 6 + internal/ui/styles/styles.go | 19 +++ 17 files changed, 309 insertions(+), 82 deletions(-) diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index bdf7990cf8a8aff509ed39d1167213b45ff92615..3f4e8daddbd4de34e788bce59a9573c00d940252 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -112,7 +112,7 @@ func testEnv(t *testing.T) fakeEnv { require.NoError(t, err) q := db.New(conn) - sessions := session.NewService(q) + sessions := session.NewService(q, conn) messages := message.NewService(q) permissions := permission.NewPermissionService(workingDir, true, []string{}) diff --git a/internal/app/app.go b/internal/app/app.go index b6cb9c8dfb95d79eccec07145f1246e6b8910713..b186c1aeb4f7d0adbc3d0fd443b660952a4def52 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -73,7 +73,7 @@ type App struct { // New initializes a new application instance. func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { q := db.New(conn) - sessions := session.NewService(q) + sessions := session.NewService(q, conn) messages := message.NewService(q) files := history.NewService(q, conn) skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests diff --git a/internal/session/session.go b/internal/session/session.go index 3792cc1d576cdd7ebd0dbf0b64670c746718da9c..905ee1cf1417b148019d9688985c1f5200209d69 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -61,7 +61,8 @@ type Service interface { type service struct { *pubsub.Broker[Session] - q db.Querier + db *sql.DB + q *db.Queries } func (s *service) Create(ctx context.Context, title string) (Session, error) { @@ -107,14 +108,32 @@ func (s *service) CreateTitleSession(ctx context.Context, parentSessionID string } func (s *service) Delete(ctx context.Context, id string) error { - session, err := s.Get(ctx, id) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { - return err + return fmt.Errorf("beginning transaction: %w", err) } - err = s.q.DeleteSession(ctx, session.ID) + defer tx.Rollback() //nolint:errcheck + + qtx := s.q.WithTx(tx) + + dbSession, err := qtx.GetSessionByID(ctx, id) if err != nil { return err } + if err = qtx.DeleteSessionMessages(ctx, dbSession.ID); err != nil { + return fmt.Errorf("deleting session messages: %w", err) + } + if err = qtx.DeleteSessionFiles(ctx, dbSession.ID); err != nil { + return fmt.Errorf("deleting session files: %w", err) + } + if err = qtx.DeleteSession(ctx, dbSession.ID); err != nil { + return fmt.Errorf("deleting session: %w", err) + } + if err = tx.Commit(); err != nil { + return fmt.Errorf("committing transaction: %w", err) + } + + session := s.fromDBItem(dbSession) s.Publish(pubsub.DeletedEvent, session) event.SessionDeleted() return nil @@ -223,11 +242,12 @@ func unmarshalTodos(data string) ([]Todo, error) { return todos, nil } -func NewService(q db.Querier) Service { +func NewService(q *db.Queries, conn *sql.DB) Service { broker := pubsub.NewBroker[Session]() return &service{ - broker, - q, + Broker: broker, + db: conn, + q: q, } } diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index ccb7f7cdb2677980ddac4a55e153354c9f220962..16fe528f736c8b40a16d664b47d1ea1e1f1ecb93 100644 --- a/internal/ui/common/elements.go +++ b/internal/ui/common/elements.go @@ -177,13 +177,13 @@ func Section(t *styles.Styles, text string, width int, info ...string) string { // DialogTitle renders a dialog title with a decorative line filling the // remaining width. -func DialogTitle(t *styles.Styles, title string, width int) string { +func DialogTitle(t *styles.Styles, title string, width int, fromColor, toColor color.Color) string { char := "╱" length := lipgloss.Width(title) + 1 remainingWidth := width - length if remainingWidth > 0 { lines := strings.Repeat(char, remainingWidth) - lines = styles.ApplyForegroundGrad(t, lines, t.Primary, t.Secondary) + lines = styles.ApplyForegroundGrad(t, lines, fromColor, toColor) title = title + " " + lines } return title diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index 01d5e41a1d9d7e4a3fa25db91caa12fb12daea1f..65fe5cfb9cb14eb60f4399b0477d6cd071315750 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -207,7 +207,7 @@ func (m *APIKeyInput) headerView() string { return textStyle.Render(m.dialogTitle()) } headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() - return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset) + return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset, m.com.Styles.Primary, m.com.Styles.Secondary) } func (m *APIKeyInput) dialogTitle() string { diff --git a/internal/ui/dialog/arguments.go b/internal/ui/dialog/arguments.go index c016b7de6ec77e6e333d2b0f18ae5930ba0912fc..172c44eba0e015ee5562507fe92254cb047d4632 100644 --- a/internal/ui/dialog/arguments.go +++ b/internal/ui/dialog/arguments.go @@ -316,7 +316,7 @@ func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { titleText = "Arguments" } - header := common.DialogTitle(s, titleText, width) + header := common.DialogTitle(s, titleText, width, s.Primary, s.Secondary) // Add description if available. var description string diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 0bf8b52d04cba248b3b19e412d981d92b4ab5a08..444492c9f71241bf812f0a96ac18d2118919e33d 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -387,7 +387,7 @@ func (c *Commands) setCommandItems(commandType CommandType) { func (c *Commands) defaultCommands() []*CommandItem { commands := []*CommandItem{ NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}), - NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}), + NewCommandItem(c.com.Styles, "switch_session", "Sessions", "ctrl+s", ActionOpenDialog{SessionsID}), NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}), } diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go index 9a2cf2ceef2be54c6f8d9897d4ddd923fd07b80f..b1977545ded8e8eeb8fc1e59c5a0a31e18ce8610 100644 --- a/internal/ui/dialog/commands_item.go +++ b/internal/ui/dialog/commands_item.go @@ -66,5 +66,11 @@ func (c *CommandItem) Shortcut() string { // Render implements ListItem. func (c *CommandItem) Render(width int) string { - return renderItem(c.t, c.title, c.shortcut, c.focused, width, c.cache, &c.m) + styles := ListIemStyles{ + ItemBlurred: c.t.Dialog.NormalItem, + ItemFocused: c.t.Dialog.SelectedItem, + InfoTextBlurred: c.t.Base, + InfoTextFocused: c.t.Subtle, + } + return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m) } diff --git a/internal/ui/dialog/common.go b/internal/ui/dialog/common.go index fe54a7e60649a222eabe80e2ecc02546036bac17..ca5dcb704d42dc6475369bf6d8020f707e16190e 100644 --- a/internal/ui/dialog/common.go +++ b/internal/ui/dialog/common.go @@ -1,6 +1,7 @@ package dialog import ( + "image/color" "strings" tea "charm.land/bubbletea/v2" @@ -42,6 +43,14 @@ func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor { type RenderContext struct { // Styles is the styles to use for rendering. Styles *styles.Styles + // TitleStyle is the style of the dialog title by default it uses Styles.Dialog.Title + TitleStyle lipgloss.Style + // ViewStyle is the style of the dialog title by default it uses Styles.Dialog.View + ViewStyle lipgloss.Style + // TitleGradientFromColor is the color the title gradient starts by defaults its Style.Primary + TitleGradientFromColor color.Color + // TitleGradientToColor is the color the title gradient starts by defaults its Style.Secondary + TitleGradientToColor color.Color // Width is the total width of the dialog including any margins, borders, // and paddings. Width int @@ -68,9 +77,13 @@ type RenderContext struct { // NewRenderContext creates a new RenderContext with the provided styles and width. func NewRenderContext(t *styles.Styles, width int) *RenderContext { return &RenderContext{ - Styles: t, - Width: width, - Parts: []string{}, + Styles: t, + TitleStyle: t.Dialog.Title, + ViewStyle: t.Dialog.View, + TitleGradientFromColor: t.Primary, + TitleGradientToColor: t.Secondary, + Width: width, + Parts: []string{}, } } @@ -83,8 +96,8 @@ func (rc *RenderContext) AddPart(part string) { // Render renders the dialog using the provided context. func (rc *RenderContext) Render() string { - titleStyle := rc.Styles.Dialog.Title - dialogStyle := rc.Styles.Dialog.View.Width(rc.Width) + titleStyle := rc.TitleStyle + dialogStyle := rc.ViewStyle.Width(rc.Width) var parts []string @@ -96,7 +109,7 @@ func (rc *RenderContext) Render() string { title := common.DialogTitle(rc.Styles, rc.Title, max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()- titleStyle.GetHorizontalFrameSize()- - titleInfoWidth)) + titleInfoWidth), rc.TitleGradientFromColor, rc.TitleGradientToColor) if len(rc.TitleInfo) > 0 { title += rc.TitleInfo } diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go index 40a8a25c57cd7cf0ce6252ef3113ce2af2f8d2f4..bfe30c0e3a04c24c71579bfbdbd06b576e1ad033 100644 --- a/internal/ui/dialog/models_item.go +++ b/internal/ui/dialog/models_item.go @@ -106,7 +106,13 @@ func (m *ModelItem) Render(width int) string { if m.showProvider { providerInfo = string(m.prov.Name) } - return renderItem(m.t, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m) + styles := ListIemStyles{ + ItemBlurred: m.t.Dialog.NormalItem, + ItemFocused: m.t.Dialog.SelectedItem, + InfoTextBlurred: m.t.Base, + InfoTextFocused: m.t.Subtle, + } + return renderItem(styles, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m) } // SetFocused implements ListItem. diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index e4c4ec664a893846f0b37fe2d4bfc323ac1773da..e4f7a521cacb51d215ca405883351558ed7179d6 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -227,7 +227,7 @@ func (m *OAuth) headerContent() string { if m.isOnboarding { return textStyle.Render(dialogTitle) } - return common.DialogTitle(t, titleStyle.Render(dialogTitle), m.width-headerOffset) + return common.DialogTitle(t, titleStyle.Render(dialogTitle), m.width-headerOffset, t.Primary, t.Secondary) } func (m *OAuth) innerDialogContent() string { diff --git a/internal/ui/dialog/permissions.go b/internal/ui/dialog/permissions.go index 8f2ca1ed27e7eff5096bcb33c8f516a07fe2dd88..143dbbd8baffa8e89b1654175039e8eb9d913bf0 100644 --- a/internal/ui/dialog/permissions.go +++ b/internal/ui/dialog/permissions.go @@ -410,7 +410,7 @@ func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { func (p *Permissions) renderHeader(contentWidth int) string { t := p.com.Styles - title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize()) + title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize(), t.Primary, t.Secondary) title = t.Dialog.Title.Render(title) // Tool info. diff --git a/internal/ui/dialog/reasoning.go b/internal/ui/dialog/reasoning.go index 7ccb575f55258000fe6246e1fac42cbb1553174a..4c5dad086bb01eb3dc12f2f6d379c87a5638d297 100644 --- a/internal/ui/dialog/reasoning.go +++ b/internal/ui/dialog/reasoning.go @@ -293,5 +293,11 @@ func (r *ReasoningItem) Render(width int) string { if r.isCurrent { info = "current" } - return renderItem(r.t, r.title, info, r.focused, width, r.cache, &r.m) + styles := ListIemStyles{ + ItemBlurred: r.t.Dialog.NormalItem, + ItemFocused: r.t.Dialog.SelectedItem, + InfoTextBlurred: r.t.Base, + InfoTextFocused: r.t.Subtle, + } + return renderItem(styles, r.title, info, r.focused, width, r.cache, &r.m) } diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 2ace3c4634a2980d1b3e82afc947a9f21b9a5541..2dadc209bced543077a143d03bbc16f6bdf1524d 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -7,14 +7,25 @@ import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" + "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" uv "github.com/charmbracelet/ultraviolet" ) // SessionsID is the identifier for the session selector dialog. const SessionsID = "session" +type sessionsMode uint8 + +// Possible modes a session item can be in +const ( + sessionsModeNormal sessionsMode = iota + sessionsModeDeleting + sessionsModeUpdating +) + // Session is a session selector dialog. type Session struct { com *common.Common @@ -22,13 +33,19 @@ type Session struct { list *list.FilterableList input textinput.Model selectedSessionInx int + sessions []session.Session + + sessionsMode sessionsMode keyMap struct { - Select key.Binding - Next key.Binding - Previous key.Binding - UpDown key.Binding - Close key.Binding + Select key.Binding + Next key.Binding + Previous key.Binding + UpDown key.Binding + Delete key.Binding + ConfirmDelete key.Binding + CancelDelete key.Binding + Close key.Binding } } @@ -37,12 +54,14 @@ var _ Dialog = (*Session)(nil) // NewSessions creates a new Session dialog. func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) { s := new(Session) + s.sessionsMode = sessionsModeNormal s.com = com sessions, err := com.App.Sessions.List(context.TODO()) if err != nil { return nil, err } + s.sessions = sessions for i, sess := range sessions { if sess.ID == selectedSessionID { s.selectedSessionInx = i @@ -54,7 +73,7 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) help.Styles = com.Styles.DialogHelpStyles() s.help = help - s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...) + s.list = list.NewFilterableList(sessionItems(com.Styles, sessionsModeNormal, sessions...)...) s.list.Focus() s.list.SetSelected(s.selectedSessionInx) @@ -80,6 +99,18 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) key.WithKeys("up", "down"), key.WithHelp("↑↓", "choose"), ) + s.keyMap.Delete = key.NewBinding( + key.WithKeys("ctrl+x"), + key.WithHelp("ctrl+x", "delete"), + ) + s.keyMap.ConfirmDelete = key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "delete"), + ) + s.keyMap.CancelDelete = key.NewBinding( + key.WithKeys("n", "esc"), + key.WithHelp("n", "cancel"), + ) s.keyMap.Close = CloseKey return s, nil @@ -94,40 +125,57 @@ func (s *Session) ID() string { func (s *Session) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { case tea.KeyPressMsg: - switch { - case key.Matches(msg, s.keyMap.Close): - return ActionClose{} - case key.Matches(msg, s.keyMap.Previous): - s.list.Focus() - if s.list.IsSelectedFirst() { - s.list.SelectLast() - s.list.ScrollToBottom() - break + switch s.sessionsMode { + case sessionsModeDeleting: + switch { + case key.Matches(msg, s.keyMap.ConfirmDelete): + return s.confirmDeleteSession() + case key.Matches(msg, s.keyMap.CancelDelete): + s.sessionsMode = sessionsModeNormal + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...) } - s.list.SelectPrev() - s.list.ScrollToSelected() - case key.Matches(msg, s.keyMap.Next): - s.list.Focus() - if s.list.IsSelectedLast() { - s.list.SelectFirst() + default: + switch { + case key.Matches(msg, s.keyMap.Close): + return ActionClose{} + case key.Matches(msg, s.keyMap.Delete): + if s.isCurrentSessionBusy() { + return ActionCmd{uiutil.ReportWarn("Agent is busy, please wait...")} + } + s.sessionsMode = sessionsModeDeleting + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeDeleting, s.sessions...)...) + case key.Matches(msg, s.keyMap.Previous): + s.list.Focus() + if s.list.IsSelectedFirst() { + s.list.SelectLast() + s.list.ScrollToBottom() + break + } + s.list.SelectPrev() + s.list.ScrollToSelected() + case key.Matches(msg, s.keyMap.Next): + s.list.Focus() + if s.list.IsSelectedLast() { + s.list.SelectFirst() + s.list.ScrollToTop() + break + } + s.list.SelectNext() + s.list.ScrollToSelected() + case key.Matches(msg, s.keyMap.Select): + if item := s.list.SelectedItem(); item != nil { + sessionItem := item.(*SessionItem) + return ActionSelectSession{sessionItem.Session} + } + default: + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + value := s.input.Value() + s.list.SetFilter(value) s.list.ScrollToTop() - break + s.list.SetSelected(0) + return ActionCmd{cmd} } - s.list.SelectNext() - s.list.ScrollToSelected() - case key.Matches(msg, s.keyMap.Select): - if item := s.list.SelectedItem(); item != nil { - sessionItem := item.(*SessionItem) - return ActionSelectSession{sessionItem.Session} - } - default: - var cmd tea.Cmd - s.input, cmd = s.input.Update(msg) - value := s.input.Value() - s.list.SetFilter(value) - s.list.ScrollToTop() - s.list.SetSelected(0) - return ActionCmd{cmd} } } return nil @@ -160,27 +208,101 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { s.list.ScrollToSelected() } + var cur *tea.Cursor rc := NewRenderContext(t, width) - rc.Title = "Switch Session" - inputView := t.Dialog.InputPrompt.Render(s.input.View()) - rc.AddPart(inputView) + rc.Title = "Sessions" + switch s.sessionsMode { + case sessionsModeDeleting: + rc.TitleStyle = t.Dialog.Sessions.DeletingTitle + rc.TitleGradientFromColor = t.Dialog.Sessions.DeletingTitleGradientFromColor + rc.TitleGradientToColor = t.Dialog.Sessions.DeletingTitleGradientToColor + rc.ViewStyle = t.Dialog.Sessions.DeletingView + rc.AddPart(t.Dialog.Sessions.DeletingMessage.Render("Delete this session?")) + default: + inputView := t.Dialog.InputPrompt.Render(s.input.View()) + cur = s.Cursor() + rc.AddPart(inputView) + } listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render()) rc.AddPart(listView) rc.Help = s.help.View(s) view := rc.Render() - cur := s.Cursor() DrawCenterCursor(scr, area, view, cur) return cur } +func (s *Session) selectedSessionItem() *SessionItem { + if item := s.list.SelectedItem(); item != nil { + return item.(*SessionItem) + } + return nil +} + +func (s *Session) confirmDeleteSession() Action { + sessionItem := s.selectedSessionItem() + s.sessionsMode = sessionsModeNormal + if sessionItem == nil { + return nil + } + + s.removeSession(sessionItem.ID()) + return ActionCmd{s.deleteSessionCmd(sessionItem.ID())} +} + +func (s *Session) removeSession(id string) { + var newSessions []session.Session + for _, sess := range s.sessions { + if sess.ID == id { + continue + } + newSessions = append(newSessions, sess) + } + s.sessions = newSessions + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...) + s.list.SelectFirst() + s.list.ScrollToSelected() +} + +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 nil + } +} + +func (s *Session) isCurrentSessionBusy() bool { + sessionItem := s.selectedSessionItem() + if sessionItem == nil { + return false + } + + if s.com.App.AgentCoordinator == nil { + return false + } + + return s.com.App.AgentCoordinator.IsSessionBusy(sessionItem.ID()) +} + // ShortHelp implements [help.KeyMap]. func (s *Session) ShortHelp() []key.Binding { - return []key.Binding{ - s.keyMap.UpDown, - s.keyMap.Select, - s.keyMap.Close, + switch s.sessionsMode { + case sessionsModeDeleting: + return []key.Binding{ + s.keyMap.ConfirmDelete, + s.keyMap.CancelDelete, + } + default: + return []key.Binding{ + s.keyMap.UpDown, + s.keyMap.Select, + s.keyMap.Delete, + s.keyMap.Close, + } } } @@ -191,8 +313,17 @@ func (s *Session) FullHelp() [][]key.Binding { s.keyMap.Select, s.keyMap.Next, s.keyMap.Previous, + s.keyMap.Delete, s.keyMap.Close, } + + switch s.sessionsMode { + case sessionsModeDeleting: + slice = []key.Binding{ + s.keyMap.ConfirmDelete, + s.keyMap.CancelDelete, + } + } for i := 0; i < len(slice); i += 4 { end := min(i+4, len(slice)) m = append(m, slice[i:end]) diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index 6d6852b7359d5f19d85349f34eff3b21c0510a05..47ffd4878727c10a4be89ed373402dd214573a14 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -28,10 +28,11 @@ type ListItem interface { // SessionItem wraps a [session.Session] to implement the [ListItem] interface. type SessionItem struct { session.Session - t *styles.Styles - m fuzzy.Match - cache map[int]string - focused bool + t *styles.Styles + sessionsMode sessionsMode + m fuzzy.Match + cache map[int]string + focused bool } var _ ListItem = &SessionItem{} @@ -55,10 +56,29 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) { // Render returns the string representation of the session item. func (s *SessionItem) Render(width int) string { info := humanize.Time(time.Unix(s.UpdatedAt, 0)) - return renderItem(s.t, s.Title, info, s.focused, width, s.cache, &s.m) + styles := ListIemStyles{ + ItemBlurred: s.t.Dialog.NormalItem, + ItemFocused: s.t.Dialog.SelectedItem, + InfoTextBlurred: s.t.Subtle, + InfoTextFocused: s.t.Base, + } + + switch s.sessionsMode { + case sessionsModeDeleting: + styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred + styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused + } + return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m) +} + +type ListIemStyles struct { + ItemBlurred lipgloss.Style + ItemFocused lipgloss.Style + InfoTextBlurred lipgloss.Style + InfoTextFocused lipgloss.Style } -func renderItem(t *styles.Styles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string { +func renderItem(t ListIemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string { if cache == nil { cache = make(map[int]string) } @@ -68,9 +88,9 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width return cached } - style := t.Dialog.NormalItem + style := t.ItemBlurred if focused { - style = t.Dialog.SelectedItem + style = t.ItemFocused } var infoText string @@ -79,9 +99,9 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width if len(info) > 0 { infoText = fmt.Sprintf(" %s ", info) if focused { - infoText = t.Base.Render(infoText) + infoText = t.InfoTextFocused.Render(infoText) } else { - infoText = t.Subtle.Render(infoText) + infoText = t.InfoTextBlurred.Render(infoText) } infoWidth = lipgloss.Width(infoText) @@ -134,10 +154,10 @@ func (s *SessionItem) SetFocused(focused bool) { // sessionItems takes a slice of [session.Session]s and convert them to a slice // of [ListItem]s. -func sessionItems(t *styles.Styles, sessions ...session.Session) []list.FilterableItem { +func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem { items := make([]list.FilterableItem, len(sessions)) for i, s := range sessions { - items[i] = &SessionItem{Session: s, t: t} + items[i] = &SessionItem{Session: s, t: t, sessionsMode: mode} } return items } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index ec17627a43fc70c945dd22cf5eee5082c5d5eac2..cd1ad42a0dc473c31b3ff280a7a224d64d0094c2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -412,6 +412,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.dialog.CloseFrontDialog() case pubsub.Event[session.Session]: + if msg.Type == pubsub.DeletedEvent { + if m.session != nil && m.session.ID == msg.Payload.ID { + m.newSession() + } + break + } if m.session != nil && msg.Payload.ID == m.session.ID { prevHasInProgress := hasInProgressTodo(m.session.Todos) m.session = &msg.Payload diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index f40fd5113bf1495fa5d35e0b891e397e6a90b6ec..cce2471267bb179bf26cee2b29c870c0998584fb 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -338,6 +338,7 @@ type Styles struct { FullDesc lipgloss.Style FullSeparator lipgloss.Style } + NormalItem lipgloss.Style SelectedItem lipgloss.Style InputPrompt lipgloss.Style @@ -366,6 +367,16 @@ type Styles struct { Commands struct{} ImagePreview lipgloss.Style + + Sessions struct { + DeletingView lipgloss.Style + DeletingItemFocused lipgloss.Style + DeletingItemBlurred lipgloss.Style + DeletingTitle lipgloss.Style + DeletingMessage lipgloss.Style + DeletingTitleGradientFromColor color.Color + DeletingTitleGradientToColor color.Color + } } // Status bar and help @@ -1268,6 +1279,14 @@ func DefaultStyles() Styles { s.Dialog.Arguments.InputRequiredMarkBlurred = base.Foreground(fgMuted).SetString("*") s.Dialog.Arguments.InputRequiredMarkFocused = base.Foreground(primary).Bold(true).SetString("*") + s.Dialog.Sessions.DeletingTitle = s.Dialog.Title.Foreground(red) + s.Dialog.Sessions.DeletingView = s.Dialog.View.BorderForeground(red) + s.Dialog.Sessions.DeletingMessage = s.Base.Padding(1) + s.Dialog.Sessions.DeletingTitleGradientFromColor = red + s.Dialog.Sessions.DeletingTitleGradientToColor = s.Primary + s.Dialog.Sessions.DeletingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle) + s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red) + s.Status.Help = lipgloss.NewStyle().Padding(0, 1) s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!") s.Status.InfoIndicator = s.Status.SuccessIndicator From a0e9d35355e7ffd5cce0840353dc79bd4ce686ea Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 26 Jan 2026 10:46:10 -0300 Subject: [PATCH 225/335] ci(labeler): add automation for `area: crush run` label --- .github/labeler.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/labeler.yml b/.github/labeler.yml index 09f0934027aaf35d7d90e3129a82a8e55b38166f..e0e998246f6533415b9c356ccae8d905e0afe1df 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -3,6 +3,10 @@ "area: ci": - "/^ci: /i" +"area: crush run": + - "/crush run/i" + - "/headless/i" + - "/non-interactive/i" "area: diff": - "/diff/i" "area: docs": From 9622c2591282b8377d7ab0821cff1cac4799c9f0 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 26 Jan 2026 11:55:50 -0300 Subject: [PATCH 226/335] fix: should also copy on `y` (additionally to `c`) (#1989) --- internal/ui/chat/assistant.go | 2 +- internal/ui/chat/tools.go | 2 +- internal/ui/chat/user.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 66459a86fd1b457907d25ee0dbd36c69b26dbd34..4ce71dda2515e5489900c33eb716e1d6d884409a 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -258,7 +258,7 @@ func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) // HandleKeyEvent implements KeyEventHandler. func (a *AssistantMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) { - if key.String() == "c" { + if k := key.String(); k == "c" || k == "y" { text := a.message.Content().Text return true, common.CopyToClipboard(text, "Message copied to clipboard") } diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index a91ca9b28355674a6aaf433d33b83ad838c8d446..e10d28e061e17c636dc9e1a6cfe364ca6f220d0e 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -420,7 +420,7 @@ func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) b // HandleKeyEvent implements KeyEventHandler. func (t *baseToolMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) { - if key.String() == "c" { + if k := key.String(); k == "c" || k == "y" { text := t.formatToolForCopy() return true, common.CopyToClipboard(text, "Tool content copied to clipboard") } diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go index 3482723bfdff519afeacb6bf7a553009c42cd64f..91211590ce66dd0dd7edbde03becdf469e26b521 100644 --- a/internal/ui/chat/user.go +++ b/internal/ui/chat/user.go @@ -96,7 +96,7 @@ func (m *UserMessageItem) renderAttachments(width int) string { // HandleKeyEvent implements KeyEventHandler. func (m *UserMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) { - if key.String() == "c" { + if k := key.String(); k == "c" || k == "y" { text := m.message.Content().Text return true, common.CopyToClipboard(text, "Message copied to clipboard") } From 0eee0616609b2c890ccc69f6a4ab3aba0b8a8630 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 26 Jan 2026 12:01:49 -0300 Subject: [PATCH 227/335] feat: crush stats (#1920) * wip: stats Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * fixup! wip Signed-off-by: Carlos Alexandro Becker * fix: css Signed-off-by: Carlos Alexandro Becker * fix: cleanup Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * logo Signed-off-by: Carlos Alexandro Becker * fix: cast Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * cleanup Signed-off-by: Carlos Alexandro Becker * improvements Signed-off-by: Carlos Alexandro Becker * provider donut Signed-off-by: Carlos Alexandro Becker * fix: improvements Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker * jetbrains mono Signed-off-by: Carlos Alexandro Becker * fixes Signed-off-by: Carlos Alexandro Becker * fix: rm border Signed-off-by: Carlos Alexandro Becker * chore: update footer/header Signed-off-by: Carlos Alexandro Becker * fix: footer class Signed-off-by: Carlos Alexandro Becker * refactor: move stuff around Signed-off-by: Carlos Alexandro Becker * refactor: improving Signed-off-by: Carlos Alexandro Becker * fix: anims Signed-off-by: Carlos Alexandro Becker * fix: rename vars Signed-off-by: Carlos Alexandro Becker * chore: remove all card borders * chore: adjust easing * fix: fail if no sessions Signed-off-by: Carlos Alexandro Becker * fix: improvements Signed-off-by: Carlos Alexandro Becker * fix: generated by Signed-off-by: Carlos Alexandro Becker * fix: header hazy Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker Co-authored-by: Christian Rocha --- Taskfile.yaml | 5 + internal/cmd/root.go | 1 + internal/cmd/stats.go | 384 +++++++++++++++ internal/cmd/stats/AGENTS.md | 3 + internal/cmd/stats/footer.svg | 838 ++++++++++++++++++++++++++++++++ internal/cmd/stats/header.svg | 673 +++++++++++++++++++++++++ internal/cmd/stats/heartbit.svg | 43 ++ internal/cmd/stats/index.css | 275 +++++++++++ internal/cmd/stats/index.html | 136 ++++++ internal/cmd/stats/index.js | 356 ++++++++++++++ internal/db/db.go | 90 ++++ internal/db/querier.go | 9 + internal/db/sql/stats.sql | 93 ++++ internal/db/stats.sql.go | 367 ++++++++++++++ 14 files changed, 3273 insertions(+) create mode 100644 internal/cmd/stats.go create mode 100644 internal/cmd/stats/AGENTS.md create mode 100644 internal/cmd/stats/footer.svg create mode 100644 internal/cmd/stats/header.svg create mode 100644 internal/cmd/stats/heartbit.svg create mode 100644 internal/cmd/stats/index.css create mode 100644 internal/cmd/stats/index.html create mode 100644 internal/cmd/stats/index.js create mode 100644 internal/db/sql/stats.sql create mode 100644 internal/db/stats.sql.go diff --git a/Taskfile.yaml b/Taskfile.yaml index b626c2fdd767d4da40b192de9e454fb8f2050afd..9ffe8923d6bbd92caf441d872726de48352b2faa 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -66,6 +66,11 @@ tasks: cmds: - gofumpt -w . + fmt:html: + desc: Run prettier on HTML/CSS/JS files + cmds: + - prettier --write internal/cmd/stats/index.html internal/cmd/stats/index.css internal/cmd/stats/index.js + dev: desc: Run with profiling enabled env: diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c08bd839dda7db5fa00cf46cc7a2dde61924d819..e7489777b5938b0edecba2b333643839974fede5 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -51,6 +51,7 @@ func init() { logsCmd, schemaCmd, loginCmd, + statsCmd, ) } diff --git a/internal/cmd/stats.go b/internal/cmd/stats.go new file mode 100644 index 0000000000000000000000000000000000000000..5dc971d1229350f35f93d5cf772239fa83e9206e --- /dev/null +++ b/internal/cmd/stats.go @@ -0,0 +1,384 @@ +package cmd + +import ( + "bytes" + "context" + "database/sql" + _ "embed" + "encoding/json" + "fmt" + "html/template" + "os" + "os/user" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/db" + "github.com/pkg/browser" + "github.com/spf13/cobra" +) + +//go:embed stats/index.html +var statsTemplate string + +//go:embed stats/index.css +var statsCSS string + +//go:embed stats/index.js +var statsJS string + +//go:embed stats/header.svg +var headerSVG string + +//go:embed stats/heartbit.svg +var heartbitSVG string + +//go:embed stats/footer.svg +var footerSVG string + +var statsCmd = &cobra.Command{ + Use: "stats", + Short: "Show usage statistics", + Long: "Generate and display usage statistics including token usage, costs, and activity patterns", + RunE: runStats, +} + +// Day names for day of week statistics. +var dayNames = []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} + +// Stats holds all the statistics data. +type Stats struct { + GeneratedAt time.Time `json:"generated_at"` + Total TotalStats `json:"total"` + UsageByDay []DailyUsage `json:"usage_by_day"` + UsageByModel []ModelUsage `json:"usage_by_model"` + UsageByHour []HourlyUsage `json:"usage_by_hour"` + UsageByDayOfWeek []DayOfWeekUsage `json:"usage_by_day_of_week"` + RecentActivity []DailyActivity `json:"recent_activity"` + AvgResponseTimeMs float64 `json:"avg_response_time_ms"` + ToolUsage []ToolUsage `json:"tool_usage"` + HourDayHeatmap []HourDayHeatmapPt `json:"hour_day_heatmap"` +} + +type TotalStats struct { + TotalSessions int64 `json:"total_sessions"` + TotalPromptTokens int64 `json:"total_prompt_tokens"` + TotalCompletionTokens int64 `json:"total_completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + TotalCost float64 `json:"total_cost"` + TotalMessages int64 `json:"total_messages"` + AvgTokensPerSession float64 `json:"avg_tokens_per_session"` + AvgMessagesPerSession float64 `json:"avg_messages_per_session"` +} + +type DailyUsage struct { + Day string `json:"day"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + Cost float64 `json:"cost"` + SessionCount int64 `json:"session_count"` +} + +type ModelUsage struct { + Model string `json:"model"` + Provider string `json:"provider"` + MessageCount int64 `json:"message_count"` +} + +type HourlyUsage struct { + Hour int `json:"hour"` + SessionCount int64 `json:"session_count"` +} + +type DayOfWeekUsage struct { + DayOfWeek int `json:"day_of_week"` + DayName string `json:"day_name"` + SessionCount int64 `json:"session_count"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` +} + +type DailyActivity struct { + Day string `json:"day"` + SessionCount int64 `json:"session_count"` + TotalTokens int64 `json:"total_tokens"` + Cost float64 `json:"cost"` +} + +type ToolUsage struct { + ToolName string `json:"tool_name"` + CallCount int64 `json:"call_count"` +} + +type HourDayHeatmapPt struct { + DayOfWeek int `json:"day_of_week"` + Hour int `json:"hour"` + SessionCount int64 `json:"session_count"` +} + +func runStats(cmd *cobra.Command, _ []string) error { + dataDir, _ := cmd.Flags().GetString("data-dir") + ctx := cmd.Context() + + if dataDir == "" { + cfg, err := config.Init("", "", false) + if err != nil { + return fmt.Errorf("failed to initialize config: %w", err) + } + dataDir = cfg.Options.DataDirectory + } + + conn, err := db.Connect(ctx, dataDir) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer conn.Close() + + stats, err := gatherStats(ctx, conn) + if err != nil { + return fmt.Errorf("failed to gather stats: %w", err) + } + + if stats.Total.TotalSessions == 0 { + return fmt.Errorf("no data available: no sessions found in database") + } + + currentUser, err := user.Current() + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + username := currentUser.Username + project, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + project = strings.Replace(project, currentUser.HomeDir, "~", 1) + + htmlPath := filepath.Join(dataDir, "stats/index.html") + if err := generateHTML(stats, project, username, htmlPath); err != nil { + return fmt.Errorf("failed to generate HTML: %w", err) + } + + fmt.Printf("Stats generated: %s\n", htmlPath) + + if err := browser.OpenFile(htmlPath); err != nil { + fmt.Printf("Could not open browser: %v\n", err) + fmt.Println("Please open the file manually.") + } + + return nil +} + +func gatherStats(ctx context.Context, conn *sql.DB) (*Stats, error) { + queries := db.New(conn) + + stats := &Stats{ + GeneratedAt: time.Now(), + } + + // Total stats. + total, err := queries.GetTotalStats(ctx) + if err != nil { + return nil, fmt.Errorf("get total stats: %w", err) + } + stats.Total = TotalStats{ + TotalSessions: total.TotalSessions, + TotalPromptTokens: toInt64(total.TotalPromptTokens), + TotalCompletionTokens: toInt64(total.TotalCompletionTokens), + TotalTokens: toInt64(total.TotalPromptTokens) + toInt64(total.TotalCompletionTokens), + TotalCost: toFloat64(total.TotalCost), + TotalMessages: toInt64(total.TotalMessages), + AvgTokensPerSession: toFloat64(total.AvgTokensPerSession), + AvgMessagesPerSession: toFloat64(total.AvgMessagesPerSession), + } + + // Usage by day. + dailyUsage, err := queries.GetUsageByDay(ctx) + if err != nil { + return nil, fmt.Errorf("get usage by day: %w", err) + } + for _, d := range dailyUsage { + prompt := nullFloat64ToInt64(d.PromptTokens) + completion := nullFloat64ToInt64(d.CompletionTokens) + stats.UsageByDay = append(stats.UsageByDay, DailyUsage{ + Day: fmt.Sprintf("%v", d.Day), + PromptTokens: prompt, + CompletionTokens: completion, + TotalTokens: prompt + completion, + Cost: d.Cost.Float64, + SessionCount: d.SessionCount, + }) + } + + // Usage by model. + modelUsage, err := queries.GetUsageByModel(ctx) + if err != nil { + return nil, fmt.Errorf("get usage by model: %w", err) + } + for _, m := range modelUsage { + stats.UsageByModel = append(stats.UsageByModel, ModelUsage{ + Model: m.Model, + Provider: m.Provider, + MessageCount: m.MessageCount, + }) + } + + // Usage by hour. + hourlyUsage, err := queries.GetUsageByHour(ctx) + if err != nil { + return nil, fmt.Errorf("get usage by hour: %w", err) + } + for _, h := range hourlyUsage { + stats.UsageByHour = append(stats.UsageByHour, HourlyUsage{ + Hour: int(h.Hour), + SessionCount: h.SessionCount, + }) + } + + // Usage by day of week. + dowUsage, err := queries.GetUsageByDayOfWeek(ctx) + if err != nil { + return nil, fmt.Errorf("get usage by day of week: %w", err) + } + for _, d := range dowUsage { + stats.UsageByDayOfWeek = append(stats.UsageByDayOfWeek, DayOfWeekUsage{ + DayOfWeek: int(d.DayOfWeek), + DayName: dayNames[int(d.DayOfWeek)], + SessionCount: d.SessionCount, + PromptTokens: nullFloat64ToInt64(d.PromptTokens), + CompletionTokens: nullFloat64ToInt64(d.CompletionTokens), + }) + } + + // Recent activity (last 30 days). + recent, err := queries.GetRecentActivity(ctx) + if err != nil { + return nil, fmt.Errorf("get recent activity: %w", err) + } + for _, r := range recent { + stats.RecentActivity = append(stats.RecentActivity, DailyActivity{ + Day: fmt.Sprintf("%v", r.Day), + SessionCount: r.SessionCount, + TotalTokens: nullFloat64ToInt64(r.TotalTokens), + Cost: r.Cost.Float64, + }) + } + + // Average response time. + avgResp, err := queries.GetAverageResponseTime(ctx) + if err != nil { + return nil, fmt.Errorf("get average response time: %w", err) + } + stats.AvgResponseTimeMs = toFloat64(avgResp) * 1000 + + // Tool usage. + toolUsage, err := queries.GetToolUsage(ctx) + if err != nil { + return nil, fmt.Errorf("get tool usage: %w", err) + } + for _, t := range toolUsage { + if name, ok := t.ToolName.(string); ok && name != "" { + stats.ToolUsage = append(stats.ToolUsage, ToolUsage{ + ToolName: name, + CallCount: t.CallCount, + }) + } + } + + // Hour/day heatmap. + heatmap, err := queries.GetHourDayHeatmap(ctx) + if err != nil { + return nil, fmt.Errorf("get hour day heatmap: %w", err) + } + for _, h := range heatmap { + stats.HourDayHeatmap = append(stats.HourDayHeatmap, HourDayHeatmapPt{ + DayOfWeek: int(h.DayOfWeek), + Hour: int(h.Hour), + SessionCount: h.SessionCount, + }) + } + + return stats, nil +} + +func toInt64(v any) int64 { + switch val := v.(type) { + case int64: + return val + case float64: + return int64(val) + case int: + return int64(val) + default: + return 0 + } +} + +func toFloat64(v any) float64 { + switch val := v.(type) { + case float64: + return val + case int64: + return float64(val) + case int: + return float64(val) + default: + return 0 + } +} + +func nullFloat64ToInt64(n sql.NullFloat64) int64 { + if n.Valid { + return int64(n.Float64) + } + return 0 +} + +func generateHTML(stats *Stats, projName, username, path string) error { + statsJSON, err := json.Marshal(stats) + if err != nil { + return err + } + + tmpl, err := template.New("stats").Parse(statsTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + data := struct { + StatsJSON template.JS + CSS template.CSS + JS template.JS + Header template.HTML + Heartbit template.HTML + Footer template.HTML + GeneratedAt string + ProjectName string + Username string + }{ + StatsJSON: template.JS(statsJSON), + CSS: template.CSS(statsCSS), + JS: template.JS(statsJS), + Header: template.HTML(headerSVG), + Heartbit: template.HTML(heartbitSVG), + Footer: template.HTML(footerSVG), + GeneratedAt: stats.GeneratedAt.Format("2006-01-02"), + ProjectName: projName, + Username: username, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + // Ensure parent directory exists. + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + return os.WriteFile(path, buf.Bytes(), 0o644) +} diff --git a/internal/cmd/stats/AGENTS.md b/internal/cmd/stats/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..b2b557e2d074fc7940a79cd0c07b3685753a8ab1 --- /dev/null +++ b/internal/cmd/stats/AGENTS.md @@ -0,0 +1,3 @@ +# General Guidelines + +- always format CSS, HTML, and JS files with `prettier` diff --git a/internal/cmd/stats/footer.svg b/internal/cmd/stats/footer.svg new file mode 100644 index 0000000000000000000000000000000000000000..06b4c85ac3e672e531981a1d7e01aaee58552233 --- /dev/null +++ b/internal/cmd/stats/footer.svg @@ -0,0 +1,838 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +KLUv/QBYlC0DOi4boTogRUTOAwAMAAwArAHAAMAALMA6vrpQuiak2EE606A//CAh0Acdrm3bBlrg +Xdd2Skn9A4kJGABwToA0XQkTCksKPgRDkQzJ0Dw/n3pzj+j4ob8UfVp+5md6k5tbLdHxK0FOoiM6 +muD4UdEM+z59yE/Q/DiYkh/5iR84/tH/zT//pSd7KX59i76T+6MiuD/Zyc5J9TRLMvzG74/bjx01 +yVH8nm815GJajqHnYSmCIRh+4Ac56EEV/cyPf/1TP0qCP/TkBv3nP+9PspM9/L2nJhl+Pi1VteTm +/kITJFFTNVOziyd4huM5lufm3n8oaG4UTdHzTNUUTdGNoqd5niZaoiVJjmG4QxREQY9+3zmqpig5 +bvEMzxD8vO8nP/l5mmiKjhtMSzT8vVS356kpgt/3URVVURXFU9yniIok+P0uirv8wi/0oeebDP0J +9q/+nXa0m730KD95ycvv46Afe+egF8Gvc7Lj2df9R3bPxZD8+Og7Gfb+/0d2Ugw7npE8JEkuerB7 +kfzE7oslunnv3fcfdrFEe9rVzn333v/Qi3400/79///b4yd/if70q59//fPfHz/yKz8z/dSPgx0E +R1MFNw8/MRzNtIte/GIpmuIpomIqqn6PIzmWaEiK5HjuD/2i2VEP7hEl0TL1IZmWKUii+0P/d/qT +n6mJmqZJmqIJmt/splqipVmSZVh+pS95mZInSH6Sk+l4jpscxREc/8jHVDxFUhRFUPxiF9UQDc2Q +DMUQDH/YQ7X8wI9+Z/iB6d/oN8MPqj4dvejTju5S5OH3fd9jOYK+q2S6RTL8OsnF/UOPfh1UP/V/ +23+pR0k/9u3RXZJjuNGTND85jlscQ1IcN6iCKYiCJ3iS4AiK6Yf6b5dfP705fv57n24TTDlahh// +XU1R89xmCX7fc3NzUy3VMi3T0xzFMgS/L3u6PVmO4yjuMiTDMRTDMPzCH/qwhzxMQRQ0wTIEQfAD +P7g9yEH1Sz/0Oz/zK7/xE7/wA7/v+1//+Kf+9KP//OYn//jFH37wf/9///lXfepRb/rSl570og89 +6Pfnv++ee+7Vjvazn93sZSf72MUe9q///rvvvfOu8pSf3OQlJznJRy7y8OOfe97//yo4Rc1Nmz3j +5NfxDAS9+Pv/RpGHZP86LpLf90WS/CNJei52PBNH8ZMdFHv4fZKLHd+0adp2xzRtGee9Zz4TO56J +YPfk5yPJwU/0X8czkgQ/773vXxc573gWht+HXwR9+L/Qix3P+D7Ys2nasm9LNjc7uU3TNE/zNFEz +NVNTNVWTn/zspz/9+c9/fud3guIpnuM5nuRZnuVpnuZ5nueJnumZnuqpnhzlaEe3Rz360Y9+6IeC +IiqiI0qiJFqiJWqiJnqiKIqiKZqiKqqiPO1pT33q05/+9AvTMBVTMR3TTaZkWqZlaqZmeqZoiqZp +mqZqqqZc7WpXverVr37qF6qhKqqjOqqkSqqlWqqmeqqniqqomqqpqqroaZbkGG4xBL/vOw+3mqKn +WZIjuEcxBL/vOwdVNVVR9VRNtVRJdVRFNVRB9VO/6tWuclVN0xRNz9RMy5RMx1RMwxRMv/Sn26c9 +5amKpiiKnqiJliiJjqiIhiiIfuhHPdpRjqpneqLneZpneZLneIpneILnd/7Tn/3kp2qmJmqepmnu +0iTN0Zvd5KZapiVanqVZliVZjqVYhiVYfuUvfdlLXqpkSqLkSZpkSZLkSIpkSILkR35ye7KTnFTH +dETHczTHciTHcRTHcATHb/yjH/vIR1VMRVQ8RVMsRVIcRVEMRVD8xC96sYtcVMM0RMMzNMMtsPmW +bE+WJmmWZkmOpmiOOwRBcIuhaIbmDk0xDM0QBL9vboEt2bxzzz8O8pCPnOQmP3nKVe7718E+drKb +/exp39x3/3nQi570pT896lXP///gD7/4yW/+86eff/77PvALP/Erv/NDP/VTPw9+8ANDUARJsARP +EAVTcPfQhz8EwzAkwzI8QzRUQy568RM/ERRDcRRJ0RRRMRX52Mc/fiM4hqM4jiM5muM5pqM6crKT +nvzkR4LkDsmxJE3yJFEyJVNSJXnZS1/+8iu/EizDUizHkizJsizN0izPEi3RMi3VUi252U1vfvOb +n/mZoLkF9sFWmT7j2V6EGStTtS8Whj+K7FHDrati5xU2s74dDrCTLVqZbXUyFRyL5Vd8trUb62Rz +q4LdVatmN6K6hdl3U8Mui5lZmH1bAS6GVsHta2YtAW6damV6blNuS2Znlvu+7FnPZT2XrZrdwJo5 +rl1VmsEbGuViFNkig2N3BVuyVqpmBWhZqjR749jzxrEna9Gx2czmtq7ZVFvTA3TaN+XWM7t50e4b +o14DdOT4bQXA6WuOVy2qyBZZO7OvupBdtlNkCxbsptjZkymyRZ1qE1CaTNUCwJ3jOtWCFDtewRY5 +Fs8r7LJi78Y0EmwW7NawO2/gmMAkx+q2w4rfeN5s26ZruH294lMFp9u2O5mO88xp8nBb1+yr+q+P +YA9FkvQk92TvfOzGzIsqd15NYuhBR/Z9oOMBuyx31rQsdrOJArQuCFS1JhWcCtDWLDy39VuaxM7B +LAx9BvrfM/EDfUZ28o//C///wi8M/f/h777rZB8znnmSiyAI8rGHXvSdz0hyJMeQHMWR9N0XfRaC +HhT9J8FP5GL3vSN7tC4o9sCtrDHxbim3/WWff9QskwDTyjXVcFvD8WmCXVZrmsROz+0ALOYVIxjr +2BvTLaduWTPr6eyr1nNpAt9WFWdIS7ntnj3al011WtbMYkyfzx507cKfuX1jHM6/r1PPZQvHBNlD +gNYV0H7+M89FMfuid2RkF8PfOTmSHzl24X9dD9h9PQM7sH9tzb7yq/WYvqX8vi96PookSbtm9iXH +86IEx67LajkHioybK2fbNs8eNAt267rttCz2ZTGJlnLb2cP0LeW2w9+zx9yiXjEC1evGTJPvlrJn +NfuaY7V9XdUEWsptu1Lsa2YCxO/bjjK2hlsWm8lgdoAze9Bz/MYnTbepeN7UcPuKpWJ02/Y4fpCT +fiRJUiQ70megOIakJz3no9iRfsx4JoL+hx8nOe/oluNWtm3zn/E/Zjzj2WNAbLJgVwXHYnPb9h9/ +2LNHCwCXplkBMHtu285sBBCAAfT/+LLQe957PXu0NYvCsbtgCaA7z02umUVNc1vHsWpqWSz9MbXo +2Hwqskf71q5n2/ZCkS3mVjWzlQx2azp2UWSPOVZ7x3/2gGOXnluQabSU27Z59/lswVHMcc3ONR17 +X0uWPea4Ztv53Lad8e47WlSsKh2djbYF4UBhYepwx4FmAHV444D/TjvQJcbLgCHc9k9AOi1XTMMh +mBMoD5ilIA1pKNvWEBayxMhwdAxpGX4aIKgql2gIRxfdctLDshEVjhFrcIABmTwcjCwiVHzb3sFy +wrjlPELF+0EVQxhV6Xwe7w4RwwUMHvWFNwy+ed62hsJqEx5fWT+tFoOIHNZtfwwMKz+dGdi1VVKr +ApTg28iXWqC3XWB6VE4aQWZQ52AIKQOmyrcdfwgVz8RCmAFk1QKdYXCVi0YeA2FUMITcpOYyQcq3 +XZ8IFU8dJYrmsDDQg7lpDVUt0IYNzXZJgt32wiNUvOZIFi6gsd4BwRjhWs4LIsCVtl3A8gIcafGl +qJLR19ZMLFoIbZVwsRHzO4EvBeE0i1MDYypCxUeiok1o2yk2b7uR1vGD2yy+hMsnoCXOzanC5Zfq +a4iYpY9Mv21F5/N4zDr4WlxjMJ/JF0Bs5ic0se0jRkB8xtR58mxb9NGEPkTxQGBEUi7QnSv+rqCE +Sz2Nh3OhaEGS8GiipNFna6pwz6mDKTUI0IOyOE+9F8Bhx2OgmQ1FM7LAKLEH6pYIiKmXXQFFLN8+ +qh0hreAEdGxu+xtFPC4vTg18N0WG0/k8zml8GBSo1DlW6KSN235j6mv9Cy0eESw01SXhtE5Cq6FR +UpJDYxAILDRNl+K0jOobWk37eaYx9Yj5Hb3gXLjHcPVewwwvJfXAGr6mGi9l2zOo1MtTNRB4C4QI +9y0JLFT3EgjzdzTLABrCZLg0Zs4tamCEEm48gRJucjlVCg6f/roGycL5BJz4gtavlJ45rJQFsIFU +tz0QCivlmag9Ss1ImD/38V4CKfeMVAO0A5fmlOAUVsqCYjLPlUgk4V1xRMJpJOZ32x6pTA2tQVcU +Wkf1De1AZWpoW5fitEpCq6HBPHICxAL1fkm11GvM3+jqQSu/6XA5Hqwzo/pqEDr5CMV14RWH6sL9 +CRPhtp2hPf0VLE4N/HVrwh1okZT/kDQ7Vx2kPI89SHmnbhygYLxL6TypmlS5xp15pjGVAd32SIpO +3CccpLyZ+0juoMVNUEv6WVEMXKoBGsvu/BsqgZQzOAcpj+4+kotRTekhUIoNvpNDJ96QZ0Q3UCkm +qAZBcDJJ3RlKPakSEqiAtNdtW+6AEk4xinjcZ7hDQ0WZhtZ5xDitW50WWsttNTTSgGChCbgknMZJ +eA4tUvkLrRVyIpx/FAh8PsN5fISSvBRRu1h/JmWs+GwyZMoMaSHwDIhSqm8b44ZO/tUx+LLulKjn +EgMRLjyBEk7Ff0iM1ZNDwOAVGoTjH82fcKiQEkoHoeKSk2byuVBQwqkuUtoTGc7jHgo04cQTKOG2 +3chaVZxqUXC0WM5O6WWPrHdL6e16QOvp3VJCYLciQNzqlB8JpmZ5bgW4M5oNgMpULeyuWlQBinBc +w3HqFadYVgt3tOyiOlp+WwHt29HyR8XoeDXLr6qr4VUYzvXMouYV7LYsVp5TvIXdd9WqfMdrt0bF +CQPUdFvDbvdK+c47wpDs4ZlaMnPl0IpXCobZFBxb8RfFwm1Hz6pZHiDHbe22BKDpdpblGO1jKjnu +5AG0HMcpOWbdGmY7eYDsvilWJw+YfycP6GiZbuGafVd1S8+tim3RHS2z8itO9bej5VYduwZ4pbpX +l9l3bgXEr1vDqdQrp2gB7cum6E41p7qAVsfqVDMIgDluye676lRzitdw685xp5pdKfZFxTJdz60A +OE5185tSxa9uflO8Q8UJNOuuYhTMculW1bavmYrRlo6p2vZFdatV3LleLHmW2VftojpUnNC+b5yC +3fltVfFLJctvTcvyW9PdLMuqsJtlmVy1b9ey2Jc7dgHiVjfLckoFs7CLaVdzu3nbAe6G+yxewRZU +fMlq2IW0LNYFsy9Kdl00/b5s3NJzK6Cd3zdOsWB27lBxgucpOpu0s0mzTmtMT/R0NJxsQPvK7nvS +rPuWmVvDZWYps7Bbysx3S+nSMexmMZyRHnQFdeqYwMu8DPIVoQGxKcfzaVpBHQVbD2g9WStgfmMv +l87mVmbljETRmcqdWdkAKbidWZmG2bnOSEvpR/95cGZlPZvjN06xGWkpxRH8sr5qMd2Hvu68b0y1 +lC7FgLbmcuxx1LSLsmBXgNYFjV9Lr1aaVYv5IBi68OdlwWWbYlXwY+tOvWoxYPd8BD8Ihp7O+7Zn +O680675s5tYwG6ZctH1RrKd9sQQvm4oxNGqZ2xq2pGK0Zitv21FkkFh8vm97gt0VzzWdtbD71rG4 +AscAcOe44s161WJGcgQ3awZbsgz1irU8WVbDrcpVsfPaDtB1S9Nl+6LYeo7lAalXp1xUkUHA7/P/ +G1sBbs2+LQd0ZDx2We2cDWhf+RWnMtl9U20KZl8WlQlwzLx78IvhOLtqWw9I37c9Vy2m/zEjPeck +SJrfyILdFu3CLKhetRjfPUmOXRTl2FO3WC6nf6SYkX2sYXeW51ZAnLUCZnmV8W4p4TjVdga0KthS +s3VngNp9XZRLu6AWZt9NHZPrmIZ/Od5kLYuNsWAWk9Ut3c6bbG4ztPSKuao2c6DIWnSbyUhL2Ws3 +xcoakDPH8auaVO68iqHof/uisrZm35h2WVkdi12pGHZlWe+WEgDu3NY0y8niOlZmlnJ+49TXubJK +dF+2jmVapWLl8NPsS4AV3etVi3nFdR2j+AC+fyH/pOdcbNUqV4ABA+oWy21bbWqOxZ2U+3YBVnQL +x/DrotrOzAoQ8KBnVoCAB+0hr7imYykTDSMU8PsawNYs2r7yi6UfAui2jqHcua5ZuJ1Vr9tKQei6 +7bQvinVnVkBXsXX7kgeoydjLCoBbVQw7WlUMv68BKkOrigkwGZEHqVPt23Z4K8WmGSRbtSrTZtpM +2/u6qisVY2iLInvj2PSccqPIOmr5fHof+ff9lYr9UI4+dtJBsv1zhG0nHSmSk2NqtVK8WIJWFadc ++ONli56pZjZG1XOpcM1sZgD2lWkX7XDs1i4dbzix5205/Csy07Zr2I03L9tyvCwU+5iFoiiWbDuX +cjyfiqyGLV7G2ZpF55qG41PVquY41XJoOHZr1p1ZkKWE21WRQWraxdRzqWprdma5GUUGYee59BrZ +YiQaEJsCtC7ItII6iowFu3Pd8bJF1nHFADMLx26dch+ZZ9o2W7MoqYJT0+c/X9Muxtz0jIs9YmYk +75E1A0Ef4Sg2K0pt9gaACLVp67p92Y0KTiHZqkW9bEO8rRJlPeubI8r4VmW8dJBlMt4zAbKeJWQ8 +u2R8DyDK+C5FnIxvVpT1rcl4+8ovt+PtRta+WAIvW5TqN8auWvRAsR8ZLEAMD3ibbt8B6k49l6LI +t8nN3rvZN0iqfZt9myja/yZRddyc7KIKfjzcIhf5SJ4cTVXOO+/df3GPfSzNM1V79x4MRT9uskxV +/rv/4AfDkdzlN795fpV/n/8fGJLlNr8TTf3/xJE0T3CfPORhD3ufi3NAKVogwbtxA555nqnOPFVe +e5QeKkMpxEs86sOhnbwmdCq/pvIRXyZhlVcPTlSfUOKMp/DihbdoMiBFAU5eCkklpd08EPGzQ9El +nzCJTN+ZRGZ3rmjUSqPWT6O6c8VpFI0q0MymTvFYcY5L9Lc9m85EA5ZowBKrbTdgiVUDllhxebSC +QCDRbCGl/ZRH8oj7nItuORfd8m3HoI9o8RF9TtBntAltQsvbhFbRJrT8tXlt9muzSjqiczu/U4Wf +nIltO4q/fSa/3jwmFxh5+LFLn1xeF/JwefXH4Q/0yS88Dn/bF89FHr4lATSJTF9hcb9CS0Smr1S6 +aON62+6ijTaBSeoGhUatB9KvKsVlUku41a+qdXStKlVlVTo2OYOowKrcWmC73Bj5xihFcKSOUYqg +SOfG6KMhfZgV5x2kZHT0AMJBSkYvJBAb9IAxwygloyM2Bdv+n21fuhSnUWRWSsMVHbZ2sqO4vpkk +BF6BMrM6rAsLKDLIA1I49CtTihIixtNfgdedJ41aR6ORiPu9O1fbFnWKR8N3XmAzBbafmQILwkgr +jBQScYW+4vJIHsmjVR7JI5BoREr7ecISKw7iFp8YFINWyWdTMvq5xTC06qJbjnAoThV+Jr5W8JgM +yAObVvAYkBeRz+T//pn8PSBrBY9JjBLCyMOPXfrk8iIPP3bpk0tioP/LS3EZ6BOZPHz60ieWbXN1 +yVvqkk8VE64ARpe8xZLgaFcJV6Eltbnt7SusygnBn3xH8CetpRKZXjG6/LYLH/VV1qPknqaNUYqA +SB2jVAOR0t4YpXRscilCi/qYLW172yF3iiNnzOx8R2XcEqGBNbzCub2frErLZ0qc8UTewPDb1kRe +pjsYvdW72bJtrztHIwSReYpCxWPF/2xaJbNpNa1d8TiIJPw5m2YTjLQ6Ct1R6KsPI62SUChEcwps +Pz+MtEpgJA4EAoFEZ2LFYYkVhyVWBfIIsQBLRk94HqHyiDyixWJxqnBHY3Gq8MWpwhfnybb8tVn/ +a7O+NqukIzKxLd92wsS2fEAeALfP5H+t4DEgLwoeA1tC/Ex+wWPyB2Tvxy798oKRfuzSJ5eX4nsc +/uV1OSgeh3951ajFkrCwJCzSs2bRpRlVTLpJZILahJZXWLSkRkzqzoiZKhVW4/vOQZGE90CRhO9A +kYQnXatK0UYSnrHuVbWaHFY1FdjuUXJjFHfxtmPERoLGmYaW2KAH6LbjTEPCoQcMBSriejFiA3Po +KIJQ8TemSkKMojA6OB8S3xo0I71tVYxgBA0QKDQB1UDSx1UsV5aGTGQGDFy996sIguUBFpAyJd5Y +Dcb+Hvx1/Bl0UWrEVBAN+VCe06+jpWFAmrWIpkCDm5HedqcJzDMpFsIMlW9gKFg56VgL7OhLmS4d +YQg9nljlX1YLpdGH4F+cgyH0VGphNPoOXvljMIQ+gVELpMXncZQomiH8BJ8Xwjm6U2zeLD5DwnCq +cJVKEqmblZugMYTmGD0dboJ2Lq0BDV088+wu1BMxRRDmdNsYs/YoHZBqgIqrg5VYAWlW6EPhKCBc +HhYIIAVjiLVKybm23HKAZbxPKYT4MCkBUhih6UMJZeiSh5g0av1ed644jRrVItNvMJHpz4JLZI5o +1KhbbLNpFXFwJOFBxUPxWPFte8VjxWMIkYQXMbadyBTYvu1QKBQ6tAW2n4W+KvRVL/QVh5FC224s +OkpKO0pK++lASenmUFKaWHF5tMojebRKQCdDQkp7AlIy+hlCRbxP3XLK5/MpdEpGP72LbvnuLtpz +0TFo/TAWpwr3iDi4s0rgzloAd1bJbNoxyqnCT25iW25iW4eJbflrs3ZEKylRLE7i9pn8AXlg23/7 +TLSCx2RAUfCY/AF5JSUDdGxy2zYUPPYArz2TD3tcXisp4WbTKuEuMPLwEQPrj11cdMsvL8RFMdAn +F0T3OPzDQJ9cYpdeR04VDrbokt/2qNBX3JLgM9xolbTokq/RTMFjgkgVE0vCSkosu5AqJqtjJOEs +NaqYcAX0Q/5MItMvPkrCbdujUtCSetsVWlJLuNdmUsffgBkr+QqrEmcqLEkt4badaquEqzhGItNz +GI5zHl0SrsDP407ZhMrPiZipwtdtNyitSMIXyEwk4TVUJOFjo2vbflWtpERNbCTcOn8iCS+PLgm3 +bU5hNi6kgelhHgV7RLi45fVMgd3Ay7YLH9VIGyoLwxTY2KMOunPFuUaBxkDxWKWIbtPyDiWlX26M +UBFb4YjF6WNiW44wkEhpNznsxwxADj9FWAykigmXHvRPozdqDSnlMBwnhxhYJX+lS2lPEVZSIp+k +FG6MJJxvfNumUatXSRzYHotgiRXfoFUCd1ZIweFnGhkPaPJF7LYxA33C2XYMUzK6yWEbD4Pr0bLI +iJkqfEOcKnzzqXyUhEM1IVTE9bYNvHCTw1b4tmGIjYQrKDgZjnNLJRKuIMN5v/N5XE0wGR09WEnJ +thkGtG15G9q2PNN7VApZEj9KRj8PEir+QmwkFj5pZCIJz9GsFtIl4U46NhUNhIpzKAWIggD58IWv +Mm6bn9+LMGKzJvnQnAkBi4bMsxkm4PCHOA9hDiXrScJSfcB15KBAkAwCDYFgWqJvA0JL3mId0ZB5 +3raoKKD8dZzVkf7QkD8iFX4dfflgnHhjE0n4wzdIOG3LQIsKCMaFJtASFW3CEI7mdQ0P6wkF8lzb +7xCs/OtgZIJ8+GAIvSY0Vd6xaoE+iFyozAhRGOsBQcr74xSbt71ARVzPkBFYYX5XMPrmOSaKeDwB +jxnvXd+DiWLRAmmve11ACZf4Bgn3M5oUhHNsIglfmF2m2JwWSkY3IBAq7uifRi/MJkTmEOKgDe3k +oI0uodXQPAmthrbtEXlxaKdLwmkH0gCr/oID0ZfrAbTiE65x4QMZBI5HD1xaPfOGTv5wEpz8FpMW +PqQNRLjQ5LBwRhZYyBKEdkGoOEMAFSWQPmikG5QEvSgIGHTYTyqjI9NI0AjiZqQ3I5CB3vbBTxkk +VxkZpAsE99gsjy6+Uc+tkBPpgDzXxkMEDsOFRc+dJnB51CWPMh34Vvmlksa88sewMAlGS0EtKiBI +Ag6j2Hk+aALbxnCCkFYxMKgQwgKDS1xpRH1EmFPbOuCGBB4HApcZMPAUWUcP3CrNpl8AqgIIpQam +JfpIfQNCS/NBAgQ2fpLDozRvW6EooHzRto4LjCRGmkM0BB0hVHwU8+uYSGwk3LZDNoE0wx2h4ocR +qfAeWB0nBJoCpCoIbQVLQqJV4qsCve1WU6hVAlGrio1CxItYx0mXBzMIZirQFxLmMzpglDalEYpJ +TzxTUUgS/l3nl2iCwQ6LqUVgIfA/kpqXnEDgPfq/EkQEjpcu8ZHUWCWUuEjatqoCRwmoKI4kWFya +0uIANODbLpUKEEypJOMgecLT1Khg0IFJiGsOtr3WBfZMwxAnqAurCfQo9NXs4lBQ4SKDYKRVwsVO +FigiQFE/23d4hIoL/I37HDimQAvIAyC+HqBOBa0OC06Atq2JOR7FwuCAoK0MJGISCoEp2962iDnF +Zjiz7UxL4lEhLcYAQgkRDsqA+HFhVrnRkDq/bQ+EZfHvNpDxEqAMUs6BEOJbDKYHBcMtNhSRqu08 +BRaK1Ye77RJqse2CSyh0Bbhtb3vbmITn0AysQ+FBADW0WAKWMTeUi8FjxVdz5cI3EBWnhGVMF4VU +lDLetjuZDkbhkRWnNMAHvdElqlMuRRD6CtROJKK65aBavu0GZLLtR2qiE+o20R/nvNj2tguhyDxv +VmGl1IyX0tk5Fj/kuEpzCgshuOEBYUi9RcusuCv1yXgxh0D5U49b/lJjID76aFy4hbO5cDoyqj35 +cnfdNonBmaiPUgOSPi4PjKc/jxdrohdIex2sLYmtc9t1m+g1KzJFE+5YHGjuqUh0MDY2iGi0ypHS +nsC+pcp4XSa19MCS0WEgUIJaICR8PCWJwZmE295oRJmG1h0cFASqb2hml+LCGiSLnU812NoTIAhB +WVEhKDwsYnE8ejK2uqbTgSgyAkKjb3sTS7S6CAUDgRJOll8EhrZtOrZ9haeoS5zIRO4UrJKObrvB ++Cljgo6gPoFoUKU5hcieFWXbpVo1QBOPuFGuGC6mIz5lqxcKDBfHNDYQBXOgmPzjizMeP0G4rEBE +sSS1VGKJyNAlv20J69PoCKy1b3vb2972woJBdUeERWj0rgGZbJuCWEzkysJve+KScBp3jA6tYTIW +X2UaV6dFSIY1NM5B4Fr/UqU0vBvE1B+3rclvW8O4iH+yB9THYoSM53kOOOUmzqweUyFqLyA6V3w2 +wBha7bethjzzKzbiZJ4lCZ8V9yY19Y4O2JGo/bYVKDiyOucTp0NNLRKEwjCl/LZFF9c7U+rEpij0 +/I9tG2TECi2IubRtJxwqfdsgl8jjiZPW0BrzvNAqokxDU/3SoUl+6Ti4JJymkWEN7fCRE5rGMTo0 +SKGA0wi6FKeVEgoDTruoTA1N/V5OoygMOI0g4Tk0z0Ebi1SEUT5+PXnGIka9a2RuKGkodlEgqmLt +cx/IeA1EaeENoo/7NSsy/bYNNus3rfjr0v22UxoJ4fSUJvT8DxIsfVAOh20rts0dOFDPHTwG+LZJ +EnoAfSSVEwiiaomy0ZgwylOG6SM5yCyGRifEDiXBCZd5njIqiUcTTQr1BoPItkdzJabARAkQZUao +7rltSsWnZge9lM5ZAyvlwKT2KJiOGUoxKxtDBWqHgACBgeBkmgJhpaSEYaUkM5vSQ2RN5rkTGntZ +cEKkOgH3XXzbL4zM8TwGBIjvQnlAyqxYuKWFNtj+AQG2eBYjhjUwdtsuqs3l32gW41WA+m1HWI5U +fylSBZfHt/11SGl/lZWvS9Q8tEo4RUMEwRRw6IIVevhQT4kRYGlKQ6VhUlua8JRqntqkbtsSGQBR +WhDN+XhYcM5wqnCJ6rPi2yiy8AgJZMJxXSCUXqpNakig61AfSgiU388hkKdsGbhcShu2Dfk8Ge91 +ASWcRHuZ/DwCJZ5J9fhZijetFXIiXKugMki7BMumGE4FJra0WKfctmsmWwjso57SFVlXlIQeBiAS +DRGTcAWSgPbzXHM+j7uo2V9Upd2XBS29Ekbp7qaSFigULtNt0weFg3kYwoVJ9bjjldp+wisZfURs +21ycGjjDW8uDdepEgFJQxDrlJTywoIoEiKcccRIgysmDwRRXemJTSu2ZfIjoEnk8ppDS7hAwSDiF +u0paMu1R+AFYKUki0LtKYIwBBOGBMKeN1JiSZyhzMkfixKMuKHGj3PYF5CaoSlCQKilcbpQEIzOU +et6Z58EigUsZbrwUNKEh/gth4eJzW+olTI0zXv0ZXfimU2m9YrKBKF4EcwIl7wqWcqv8X4Kp/Nv+ +KlDpETLl904iXXuchLlSKWJzEV9RsEo4sARKPtFEgOKhtxX6Vl4GYcqNTP9ZkfPAchdS2ruyZPTO +cKpw76AWwHhEgWglJYmELxVQPwmY9UAIsB7qMKion8lHgQag5GO5Jh8D06M864qcB7YrSlLaHWPJ +6H0tsL37uBwgC2URiJX8SKWQJZVvYDI6kYR3gApsb6CktDcMpwrvOCiPvzqEirMNDOr0PaCEmzUM +hOKKUgsExoHpSYynvxBcekp5QGydJwjiU277fEQ+If+MPO5xRsP4mho1bJ2rBUIL6TQjSD8ZnbOM +PL7tRXdYdLBn8kUbj9MquuQ7ii55ztl4nBZFEr7DbDwec1g2HofFktEbYMno3LnZNlmzFc4wnCrc +f1aP1y/y8DmwZHSuWT0Ps3ocwaGuCBxlXRgWj5VjKAixUqdKMJOEb1AEHghp3zbj8VAoVLyk+CQe +7x7rnqYMmnxSKOR1XXc2KqCEaxT6ijMqoIRjKBIV0DOcKpwmgRKH4VThDZPI9Ae0bXngKJLwAwcL +k0p6HdhusVQUamxroBxCKDRQ0pj4VY4UEvM7sktxGqkAdmiS111oCAjPoW1+6dAgLgmnXVwSTiuw +DoUmCmUaWooAamiihFZDi6klhqhEIb4m1Rh+25tK2ujwVvFlZvQEpYwmNpRSbRB4ilFy8gdYH+Av +xaniaDUeTzUCKV+/5ERdOCpEMNAax1T4tlsj0cpfKynptEpns5kvZDwYIEg6FYgETROczGJoCBHe +NNGlSDrRo/RKSJvYyjlNfeHbriUdh3b5pUPDJLQaGu0pPRtKxl25KLMFdeHbzgwkNhQMiMXxEqcl +1SnkP3mC8fUqEZ+2832FMZCY30FiEj17CWnKCw4vwnnk+JCgVaGRnjeUL7UyZQN9UD9iswUR03NY +Kum5QJE5cQSHceItOAHinFhj5ZVSSeWUlFT5fzgEBLjR4iv4Xk4jX3dhATsYhQa5a/+gssVrLKnz +aoLc6C4I+1Hq2IHAix00xL+EBwvfajxuEkEp10xqCpOgCBoJOiFpxWbHxJ3nFoFm5QSxYeVqyKNy +yYSrfNuULwXhXo3ptcxaYmibx2SeUXhTcczrAJMoMhzJgVIYgwWkdeCbF7dxQTv8eU4oOttcUww4 +eQXhNi9NAjhKWHCnyaRYrCZQLOOtn9ctaHVWA3LvbBsEq5AISLLTDrQITi4KC6NyiSYYphM65Fab +wHRg14YaQp3DI0QYFTyOWmqL2qIVDA4HEwY5tpUEG4s9S4vmQrAEM6GPeGwOr2BmMbSS9pnY4kCR +WMQLDzv4rJ2CwEJ1emucvOX0eFAqBKaLQumejGfIQAjKjjPF8oQLbDfjTo5IPCoFg3SNuj2bFI8V +jAQjwUgwEowUEhlQBbafZ6Gv+ALB0W0zYIkVImaKQXAH7ngQMRWhqk+E6mwMbPsyZnyqvi6gpHIi +FieHxsBFA2VFBEq5RcPHPkZsEBs0PtFIyejomZgRYoJGIFSbR8GBJgC/YggvlcG5qIyVcd46PQts +1lTpYDnh6kEbAgGEmGiIwKerv3U8ODsIH1zHMxP5tq3ZQEpzh1hHEQ0Ja3D76/gHxpc0M2J19GhE +KQLMEJJtZdx2xyRoRx+rQ1kShhB0aoCaic24mm3bnEqJs0ijbbvQ0oIjegJOqg6E0uL7hAaSZggR +lsWh8y0CmR6opHS3wSIpIUMjIyACAIAGAxNgIBgYFI+IRhMKtdYDFAADTUIsPkZCMCggKhFHI3Fo +HBKJYzGMojgKozCSYwoiyIwoACC8xQvuQenZLDoEQxstGu6mSSstBZCIciPRgDcXmPsXA6GDsDDS +rrvXTsvFSFPEXI5E3thmyJRL1wpvZ5DhWDpLQvQEl4njg+5uA7xDlU1GD6a7RQRvzcNvKQLjiF0s +w9XH4ur+SLBxNzKwu9rmmp50CudoIvTi7j5wSzRyQTtbgaE8SdI2j6jJuOb2PTKMf2rSrNbaVsQ/ +cjWDIyObEKGjBFBF5ekxhl8zAGh18+NF271dgjJ4e6XOqyb810thn3PTdlZuTzvdmlDba8xSJ6K2 +8YaOWcJOeYsunZPF33a3NPxbJrQNCAqA7HpFity5UJjSImp7JtYgUhLEG7pPuZ2/43iwlOdpMli+ +/BRevKjtx8VtI4VNkBln4sXv2EudffbsnHVsZ/zoUeVuBj3213T25BC6smchzWCLbNqxK9APKXJX +GJGPMtYeZkf2RfCNNP6EyMF79ZGeEgg57lktivnqo7ZqV4b0fTLYnYGz3mL18y/NxmY4mpR1iAcf +8reGfJKHnCSwJCVx4Be0AmLwFtVpxLZQEVJfgcM9qxSDH6KlsjJzOEzh32hbBbBi6+b34ZwpZlan +84R47Fk8QSxr4qtJ9s08MXkNeiAj0cvOVo4fy5VLP67HM5yb8T06qEZ4MeGC7fBTsBPtAObvg1nT +14nm4/jhVjjJ32CMSjuSzARbyXJI1gT0SErVRfZ4rhkvMqgjl+zIqg5MfwcXR2od1CF9snrbCsg8 +zKUUgV8HBGjN6DiM2eFguxaeCPUHSy/FlL4n+W/zGsJco/UpwrpGwUknlsTZyNHkg/MmdiuKbI63 +g8Tu13khMFvWGs2qtW7DSUfuVh9Dit+3VqXWBFkDkEkOWKQWo/Vo+osYIlmipx4eVwVXW0M6skMN +Mi6UUVyxYNTUy6j2ZiXHrtRx0OxeoYACHPgtM3MRdENuNnUc+l/B+wuk7WYI4SWPg9PfkNbt6TOi +4iQ1GMxeYrsjkiQnaXjqRs5ag5M8bNjQFknwJwmR5wKAmxUmWh5clHn7b4xQ7QfOpDjEaVchBanG +8qdlzNIGsokk59UfdfKmThrMVqlIk1iO3UVdawqkl5lmmcyNxSuoRU6iVxao2CNi3Yr1B3nzuu8c +9cudKCOejbCrqdjjO+HGfpwowgeDFHxGTxt1t2WjtPCRQ/owHJeKFQLtQWlo/CDF+qQoHW9Tf69j +/FQbNVdZnn84axLUG+2XC8tCAaar0Tih0iHXFCz3bTLLxHrki+faonNn6n4c3M2uIukrJ1n+K84x +LWI7nWvEyWxqbhJIORCVJTa9aLAGLBanxqcUnUNYAYBsY4EwuJZCh3LixN1vuj98de86f6ImjEv7 +Ew72IyG0Z9rGy9P9U/hU7tCQa7N6Cknl2XUtbO3Zty0a9V89tPrhynEkh7bx82MOEKnJ9Pd3sMUY +b+SKm/j7CYJP+h4mjei/agpWnsVGg2FAkwJBCWeRk269I7pIVltfFdJCWuFTJa9ogBpt9LJLCrYd +auwB7bDP1xEbPrxQcPHo4Z+4VprXL1nAXIRopZb84lkq4dkfoD91+W/e74xKE+vZszByQ3B/eRce +WK6/T21sHvI97VwK5VxkMVma7wwlD9RL39jXSCZ4QbLEwAns9rltecgymNSVvPlHlC26XhU+ef3H +RulGf8FAGv3dyxfKH7hkyRKKEsYqV2dhGVuT8qwM+xCvTJmiEDNPK8zZFTcGNSMDA/OywUiIPqLJ +NJkO5BC/Htk87TeHRAjVUx0W3GG8JcPBcMYw0KL23xQZ+nwhF2pzmEteiz1GT403Q0a1H5cuMgSq +vUWaS0wit5DUgrKmjXwHwsTxVKLNHx+w5Ns4J7FnyI/e15CH28b/oNo+3y+NHBhmoym5blOjiQ9X +B8mWdqkl4KQXAcj/PCRbF5Pu7u1LFmYpPNh8TA0k5MLzsZH8Sb4/vrhQgaSY+z5kIt5gaGdMeBeV +YdwVUiX3ytr2vctFle/4+2sYk/74fR4jKk5QQ6dCKlY1IR79ocvmZtWFO+jPtS90Rl9sRjdFLG96 +TCY8O04daYASSXfMVaR2c+84qzYaigTPhVba3mlVq6VmXzoZRujLYYUgd7OKzCdnqIsxVk5HUk/V +IVyIDgDH5YeFBTtY1niFRuiIH+6K2a/KqCXoaH1CkMiV+wP6jZGIyErQJ+CJFDgz1p+i7pSTkXJC +vYIpypBm5XQTucj3ATFfOdHWUm4T5ngQSqv5ez6U5/f8CoX+k8aSjkFJWABredZbkt96mHK9LaCu +CTVFNykTfWa4s956wBhqI0zRYkv1WaHuNSe1OxFqbUu2p5CXuMjLu+6pR1AUSf+fkbIw2U+xXSm/ +/xVNXwY1j+dM/BxzGb8uLCGANFuFZ43MEIz1uHsn85bdvPk765+hoAUgd2/OhmIm/2NYrjz17GPl +lgKyEizQKe5B0Oj+92R3DuVXICGNpnqV+O59tk90jPxw6dNoS7fpferRgICFnLT/t8laXNa3NQ8X +Tg++kJHvP0F9OQvkPhXRXYzNOucyYIrnn+HcLlRZnN3UDgf0mXEkeYrIPi2PKoiJuC/UdZKAMl9u +KQSgflYV4wVXl2upMzxEgfd6CqBPaLVgbpMCUOLG0G0OMqWQAqiluBfLEOGU1ghNkd4LxowN91lI +dAOiQ4nKI5vY6MWO0P8+2CHy6vbgv9BlRcvrEXHEVQHL+DDYc8nSnV7MTY9iKFJipSHG42IxRiIJ +7ThvK2+m6OxnudHxcIpuK6OTc3R0dEWH10hRWDcDtdOFQrXH6QYfp+fJCujy81N4B7sj/eG5kjQ8 +mAL3G937zaIbDkSNSBEILzja9dhjFvbQEYuGkx3wv7JIko0dqa9gPTAu770SZ90Vuzr88ec4sQcZ +Vq6umg3+62cLaKaF0lPhyd6j5qetLrel8oaiSTmxWhelI5m0cM2UmHsvWBl9hberDrdsQFPZVu/l +fwmxhkoE4RvdIszUULIP4O6NJU9CujLsf65MI6RvYrVPXCZnOEnQXxqysnOQdEVSpcIzPXxNg3RE +cuLsjy5u547/PlEVLtcQNP9qBe0XY/WX6VdPxB+mmYXOW+uEtkqEpGNkOnekILw2V6HdyYdlwwvd +m6bDRUKxwujQqsx8gsN4RU49zu3dkmPNm7uWj/p7agTszY3yckl+S6EPJ7WGLM4U96IcI0sc0VZJ +UtwuwjkElwHiiTuM4Zo6Ke63ZMdFLUwUHkFjM5/ivuwZw3LingiwTlP0k3q7kPKFy+8nV66O8pA4 +ZI52leKu7FIiFHMtZ1KPjp30uAlPHWkzIZY7fIFlm2Hy4tCvCDp2pNdAyk0ajwqFj7aUDlOGFaLw +BPSiKeucsH5pTK+NYJ1XywmRnVCTmIdB15lzPeucU+m+8BUoo3uhzl3OHT/WObLvI1epdRhCeLu9 +2On904GYusDojTdZeeiZ7u+Emx+ebOHvMqHIkR9kGcjhlXInFAQ3eAeD4cDQnenDgYek466YVs1n +5xHgfCEAiOZvTAvkn402eBaJQ+i3GrV5NUduFYmA0I0fug0ZYtKMqSD3/OsjQkdElLSn4jn+Z3BG +6LjA2h/gnRiMUNEANwrd/30WpfF0YY575IC59GeG6chXCHOHElrYAMzdEQcg9gntA+ZrvaZl1xvl +ZGvEl3Untm7C2+ZNto7+usZL65Ny4To0ctxoE+UOzHt7terCHOOpHhw88snWsasXJ5m3HZjzwhpC +mhjnKTLyw81SBOQx4m7D9IuUcsQ9gw0b/Bii/3CLTtFupImLuNleQYEcy+7NIeKu4G0TcXDxeaFo +UvKPhZqOuIvm9QMi7h+nRfQTVFOcuj/+bZZ6uMWRpKgk1QFFxJ0rPvx7KdJwJubttJSGsIAvbYyJ +RXA1sTITK9YopCnrqYM0GzH8VdOZWCELJjmDY3nK1HlMrHCbiVFmHhlh5kwQxM5MjFBhQNc0QJiC +IPcxo67NNHuuX2rDMbrgMsxyhMuJnHaR0RGP2RVmlgu3wlskw7spNkADsGZfx5mYCpvmjf3pN90j +9+ohI3Xc7yHZDri45dCDwumMvokXW5Sw86it3SYxTpFSfVYoYdFZwu5v0QpldowiSUOxyLcT44Tj +zWmYSIGIoTNBMGlN6ckImWB32zD2trL1yS0n2Mmq+8rKjplg5WoszfjxWLkCWhgtvkvM+p1ghV0H +JLjeLRg+0LTF7MlMARNs8q6iT9V4m/NFxSZ9UP/R5NWX5xEmGESec4d/qVN/geogHGQArN8kPt2K +KSMVWi61v43utIEaj2Nmgp0Jt5Vk7k9Ybg4knujn+2gmDyBU0KXyQjCN1ThzEx2H7glzuvt5Wk3g +RkUI/w8lM3hSR0AN/Vh+S+zXrPHUYEodiY9IADvWIFNSwnVAfZ2LOR3gMFLfZ3rTOyRzmmb3TFuF +mUAy/0kVmtxfYguid5U1kZIBkXDLsYmruNtOK02W40fvRbaFnAX4FeL/VrLR2w6I7av9v0C8OT9b +V6ICf7gLfIIjtyBja3oLr14pWvRerMNI0KXI2HuGzsrmkfCpeYEQda9V3yzTPi2y8vnfgv6/JRof +kU2YdidRBFsUrbY77pVEGJTlCKM/6dfj+2oWQo/kmrvLqpJ2wSjTevvSTPBmpWwtv/4mK3agUogZ +Upa2qGYW6zauUo44frpV1VoiwxISoIMQ3lqcH1gHxqacbIU/1yJxaehifSLvhnu3h3WxFBekV7yb +FhNbXvb8eOzKO+NKDrgncBVwi/YVzNCylOc9iRYfngZPOmrEpjaKY8HAXMG3VPrNAT0jfxaNYlhA +Fn8j2pWsdybNNBIx6MZc0pQnsvYh17tZmDTbqBDQBH8xMH2ALQnlmCPT0hSo7ntfFaj0sPpO6Mo/ +DldQw14p154oWwvXtECCOK3+fw8ZtQM6QRXes5crccLbRvN1PZAhYTvfyaQTefgqCFG/AcZV/nhk +ztetYkfQm01GSCfms3+q0jJZZDvI/Oa9oSgjWt0aAfA1peVB7Iut6I67mD/mkDTJ2Z+n2vZHTEJr +MK+MsFx6ebloSDy9B7cL7cuy3D32pn1N332Kj0pfvKySNR9wDJWT2LNre4jXNKhzp7GXtQeSkazH +fmaySsvhN0+Dybol6NxvpIJs3SfMrKkJCYwmWk97b2/rsDb1QlKMa2um2xpaVkr2s1VkJvlZkUU/ +yOnpSWnKMP/JZqgekiMx9ssAWwW5WOk7uBwgQQ48JDUj7BNs0+X5IlxUQIyGCenDUMJq6PU3rzkX +EKCKEzkMA+D7Y+DjNhINSsRHyyrm5LfMo41vaEivYgwG0pmwxHZagl26VOMozLh0c0fur7QncC3A +oxfkdIXKtibhtUDyNJGnr8nh4dXOV2AoMry1yIImfa3EaIfHvZ0KGVzbkvXJlsPUgoII4ou0JZ5r +7QQcpv29w5rXdunqZK6NjazSPUpCMw4tTuvSbQrnpOhY94A8xV47Ep5r+0++vRQDKGs0QSKz91ut +DFbAXDg3VrqNGNqK6wjUFWFFXY9RuInYrmKCj/XlksSguwn9zfkC1BUTyzH8uAQHdfW+wfAk6gr6 +BVEgUNeF+Nv5UPnfYhHUhe5IMvwVddF5Z63TAXXFshwiz4JwLqiLZqKOKqZuAXXdZA1mEnUNldER +qt/jUJ/H+jUkcgAHXwqMA8YkQTFxJecTLXEgxj5E8fCKbxCzP8UaltsF++Mk1opTyzFLF/8B/1GF +EjEox4qL2eb0pzrGaWHb/ss0ns6730DyrP9dRNjN8mtqhoQC7BY1qHdqN8YY8ZIfSXeKp8pHr/8J +yjUar/wpzrXsjxTbLZ6kicz9II0wRx4Lq7zasDbwpl5uE5TtJjIugp7iEkuYxEkYzuLGETeR4dVn +UTiLV8b/onayB/DwYnFHJIlAjgS//5fZlbZgrGFTUD2clxlLtUnPybS5DlBkeP0nCYsBrANCjcPy +7cS/ZEj47lzj3pzAiRZ5V2A6VPr/rx+d2/0WgrEXz36z8vW2+8umwUMMeKdSjK3rblbZ5rTJ0pfS +kWtsp8VK9kE5+MkCI/Fj77TGlzn7jF74MaYL7CwWDwjeGiRzXhjo1cELKs/UzVfLJ/QPmP0ZOW1Q +jusSBNxo2+fGRyz59ZOrkDLdvUUGXf9+EaQ9++/4MDOVsnyWFZfIjG5Tt32uIrcnpa7ADq7QWRBa +Si3RwcHWpJ1g3agxeeqieBY6Z8EW3nF6KuN39UoNdi4xXeoqTuMvdbnh1LWIMOQApa40vJrsw+Fn +qqPUFfVETQ0ePfXsHXDSqSviSqTd4tnJwpe6MNHgF5njv970iBI47YqvJaioQ3kRNdThx4Zm3tfK +2/ScA+GqsC1VvRVyZDDyTWMUA3F8jgzy6dzZtOJtFijTph1/m8sY948AgRxz2dEwjXD4ZJdBPBhA +L80eYwXVB29yjnZJnNhbNMJCEHS5Ap5SI+n+KxrrtuzXYnkD0Q29XFv7c1CQGqT+hdSlpg5aPnJ/ +nUDCo2NisiD5YaSqS8yBwNEvfSJE6yJdKP1mCmjqmdGTB7mpuyJ9KJAzME9TA1ZY0S1G0VLCOuY3 +rEuYk7eWoUBtLiQ+zr0UXu5iSmmtDJe6V1vC4XZO+FAKk8ONsdcc6IFCFgdwBt3rknVqsfUCcUr9 +Ir5xNowOEaz/EjH/h9G8vjg7YCaytWSEmfek/WTcO8DZVhK2Uk5lDbZ2ZAyP/TFtGWEFnR3y4zoz +bqVnjiyVxqRv4UC7OB9qEQsD+1CE+mYzC1Yi/qnnLG2uz49dQ70zlqWBoCDZCVxqRjaHeqFmcmTV +5Baiph84x/eA9IsCPhqO9ebKKDykpC8ublKWtURQq7WdRC3r7Vx2aj69bUnHczvrs4ZBYXhVXylQ +VwHGhNmzb02PXqUx7c2SS7X84SdHtCMvGj7TicndaGCNjMeSk4vBr3wwIzRRG026J97IIK1WtOiA +U+AyC/BXYDHY/qrFXPKXqixYETkqcDBPc2Z5ot3mhF4vGfwP6vBNaTR9RvZcV2Uqr4w3HtGonJNW +XgEa92iJ9fif1XQQSlkrEUN/eifPwwkvA5fXsfcFhPqSD717AQ+KkVBMYS7mleQMHObRyCpqURTZ +l/Rne5kWV8/KubquxhpY7fj8Hl+NoERVeLAgw4VXMbzr/aL8W4ummIIdHiSWhSDtnKuZFepDLRyE +YcTkqFMM2eJF10eMKJIz5so1ZT2BY7rOezZ3jAa5DxYNzZSKvWXFM1y0aZXjVJfJ6HJq5vGmZTAE +rmWlo56ahzLcwunJc6VRe4kvG3I1rzPc9lTS4OHFsZwf424bFaRfmSwYEQqM1x7n/tGvpRnwuggj +yivEJOKp3FqATmHa1RsrV0W55Shzq3PGkTLoMLdcs90iZOWW52A6AQSwk7m1IuPr7lIot1AcjbkF +2ARQbl0HcPqI0Hhf1YpAVG7dwJeV5wxzKzjrfyUyodwydoe5FTssk6ag3OI1vtb31+Ki3Kqzg+1m +bgF5gxzd1Qq/cqgyt1gjw3rl1j3MLUf/bGVXSf1HrdzahJK+IWcIKWFR2TLE1XZwd41VAEWkb6wT +VAEKWO94HcDSEJ4sXrp778ZnGTyP14qQaFCDr4vcHuPplN66F5Onk9IzInAVFYGqyGAEtGeIazNo +i8ntvcCdjHJMzTZtT98W57gC+KLPuf2gjbZLoENYY6t3hTZNzznfRsaMQIJ2SrV2INwWzFWQYIEO ++i13/v9tAj3ClqXsz4khlpnFTbTNh5lAazpC90JRJxmAJSbQPC84OAq4FDlcM8TebWZL8dOwHITR +l+BEqbUpew7JBF3K9sp0W4uZ12jXxpmmTZbGY0lbHUTOObEYY6Fu38MO2olCiaKbw5Y2VG+hhMla +pcFX38oiKTVvymAWdeumEr3Apgc7UB2vPe7hMelBAnpCwUmaqio54ZexMap32rNTi3vIsaxGB8q6 +ojuFGUzoaOaWRD4YGLO54op/Iumqkf63CjWVRTH/IqJ7GTPiAIdiTLQ61CyaH4ssiO1CKGYUuUQJ +H6oYJNHPHTz0bZnb9MwjFtAPDyqBEAuvGEtcKjeiKO0fXIXnL+JEO4eK50N5c1pA5SBQTWRe1+e0 +zKF4BaEkK6RR3p8lejI6wLEJfSn1QcVka2hyvK5g0aWAn2lDeXiqLR5L/6UmeDxJ6UuGY6xzu09K +40vagGDnx0xhLGvZmA26CHhciDSNDO0/8Gi8B3Gg1YiEKO2CZpsGFQ60uNCQegV7OdAYRjgIWm5f +vdA70CIn8iQEbQ4nwMpHVwcajgaS2r/1EXrMOIogwNXVlfIh2ctwYTnQLlvleMaGaQf6r8ZTIC9n +l+LMFA70mFwGQXNxZtDZgaZvs7pTV+Xq9suKK7WvVSYcaLgbK/0/aAR9fW8AtDWou4FJy46JIIO1 +RSdrmRCtcjQFY1PcyRHy1zop6GUDO0zxcjRvGawckfmpI4XXMoFODt4Syt5o34ylp4BG2W17BZzo +1CP661eWazcHH75adCS88fc1eN3ruo1sV/iGS2vU8qQ33BM3Ctam5KVNaG1JLkN8TP9KXQQFzTCo +tyMXTVfAcbYKl1ZRN7e1RYZWcSmYr/rH2v4YtEnZXJfaXl1Whdjzh8OygpiF5MpPh30dBWLAD08l +6+VfmFkZxRKPVLCjUbLXSCadQXhXQwqJHqonqexny4qCdcWz3VnB/qY14SQvAzxRLkNycThQhpUS +YFLQcU406CxcRbMjS1YcOCFByXlgM1ikszEdYXaOjNzcqcDsZHFFTPWCJ9cCdf8j2gBS63qWn7Is +KwssGGngqULabMHumHLEypuTjnLQgtJXCZPv9UaZ3HtAjhnCYQmI7OpQhkRcXXb7wgL/epCIrvr1 +PBHcU8rAqqx1ySDlJtl4JoG7M+3XILwn+6tidcfspz6BmwaWD7ARGAsZB0qXqUTc5SZxyFQZPOd7 +AhjjJh0MdEdXTHleulpjAH37rA7a5tuFSzVmhWEz3J5LCxdhA9GIhuNnlgyHxGu1pIRDhPctFgRe +E5FcaAjezzGvvHXi7ggS2dEgPvNkgMT5+/RFdKmIkqKeR1vJmT+qDTDq/G9uzLwQEYmWdwRW6+4U +LtkTRKt7RylylrExphJyhvXz8uhPJFxGsE7tqc6/KDnfamseVGA482WnF128JdhHnWmcDwEHi0Ve +pInzVOdsY5YxoL7oZ0KdCW3Y8t/wMo3Ot2gQEnYbduCb9oZF972qAdW5agbw6XAZ6vwV4iiOEThC +nTlZXNhlNihEnWEVv9Ay0hEJcDn7a+om9EcLPZsVLi9oBNc9vHIHnCDpu8IgSkJdTnKaBIkMbIBS +TkBhUM7bAjEaEuFUgh9ME3C7QV6wwB2BhauJYfPsuonN58mnyHKuAnN/GP24shxWSpQNrtd/E/YC +nmkrxQfU8AZ2OGTuSv5COc/01LSaNdAqC4H+LHjhOcYmnpMaYClv+Fa76LA/Vw57WJa1Be2GZvHJ +kaqiOCwkdyrTQFUwPQrEZ77HlK7sUqlbquL9tnEcfgWUP4QmheckSvuE1g3qBmzWNBwWxNT3hMKV +jpbz8MXNrDKwpNCyQjCg0Ky4NQ39se/8zDrriRQLaVi44bqo6zNTxg2hM75EaAZPnFNLbSCCYTO7 +Wbee189bw5sNnE2j+cx8VsK6Cs3RyYjjZ25KQ3MA39HLTvszk0JoTk2pWDnzf+ZRDM3O8eGsXfT0 +mcF0vUChuV+s5bf2k58ZNiBCsy/QZz5FABxDM75bbJnXZ74ortAsrfszDwhgpqGZ3/INBlj5n1lU +hWabAOj7zJBiCc3KajUYE8mfGc/r2Uho1r7UF7X4upJRn3kUejNCsy+kUkA6gc+8vFdTa1cRQnPO +lQD6M7tjoZm9uYb8zMYiODTfwqprycIchEholrWuiC0IzV8+M/Fj9kBoHjeMX9e/3M+8MAs3+Qkr +1GT8oqMOfdpCYNApyYXm0EfqJaH3MydrPBBsFLk3t1Au0+szY9cojgnNSzV+wP6TY4ColOOUf0Nz +fl0VoFdFf7Ot+rRQcWGhzT4OWL3FUkxyP3MBwTec4VTLtCmTTQy+yeijJyYz6GbwB/3DijFjEvXD +6/odqCo4EnnpYNXm/qbfZBIG3rICVeOM2qGyaOJDhpgb35nEhdRmOuunttP9IfitLLK2x1cOXPDD +siC4Y4ByMiIUkM55kw1xUmH1wKoAFBVn0zYNJW4lyheQZWZQ8bbCrWCdluPbzkVTPTD2HyDluZll +pkGofCZgy+Nr/nxhWCBKMkwTbiFIbhM1BvPHlqADZKdOhlNCu0uDBq0jNtqMPfZV2gYnU+Zsu8hp +dNwdWXdWHS3s6BZqO3O9Z6jjmeyAxGX0Sh/NeLGnToPvUxHqxdZ96HCLFExGoIbjY4n5HwR+n54a +tcsCbjyeZ++pMij+k4j9ixsrPlyNwvEjWLMImEW69ocVQEzEMYZeH6I4P3J/laLa0njS8ZjEHeOb +Kc7j9Cw/SHjLaxHnEl9XPN7zApk9dEdUxYq7ZW7xKaQZwSGMmmZ4+P2cZUfS3N/3rfgt7u7JTGoq +5rAUEZ6zo56bZreH8URnHCTNBJhAgU1zKmIjODG6QXAYv5rmvFi1s7qVCGkeSdNsqb+uMOUOooY1 +za9sm3VWltKLJQjkYZq9n+Wwj5Yf2yBpBqBGW+kgVLBxObnlnApQxKNvBNhIwB7DGYqcm1qmNM+i +nZOVhyLp+Fq47H8BcczRU8UWJyI/lhZ4Xxv/YFPrB9hXjJsaFxovJr90OqQ0ZmvbrN4al83IAS3t +j7QgGmhvxwW2VA3wR7x5M9UwYNwfc9uu2/QpR4OVj52pelKIxVJFzR31CRwTj2Vtz+QI497UMBt/ +3vpndzYEHCWhK4tzwMJbN8SCY0EjMIpH798P1XITKI4aOtcRQ512sBmjiaU71svFvxaPXSrDICQI +7ph5h8Cjw28419OfvB/aa9QMRx5dfiM9mRjc3UvNdewMSjEefYbKQbCQgZ8wx831ylcPdFiWQLXx +CdUNtZU8lh/fdWFIMJNAw5HZwZVv6VyfuC8W1e6H8e1R92Nmarj/TlGwMwe7j+XX0CDYWnq5J9Ef +oCPfY26yS+8zrnDsljFGpy2sUBGsjQ1xgTLiNGQQPKMUOKuZkhmwx20Z7UhlSOjSSIxUwAZtwVMl +yqCfkr2xDKMDND6w+fQB1GBiTjxCv6CtMnTnjPOyxOPej/uTtRlU8UBihnfD75+T1nhDOwewNhtV +VjnWZM5nM0RFVJtlCVywYrUBMQn+a2cAmr2+LtjbZVsEeKCt6IU1b+Mox3NvI2DMtCsqNet0c2EZ +C9gd4r33QoTylxDlMYlEW75pDHRBhCARlQD8Z/4SedZYVJ0LaPk/CJ1Z09m8iYZiofPQt0eXb0KQ +W+ewjPETFe4T8ppBA3k7Qv4kzlYTt1tcC8GcEknQwZRxPj1MOK18pL7MBuCbrDlrXUM5F1i/tD3L +9n9dYwsh+lZhdaLFNHTVlAfYxt1AnnLkGBU03whR+5yz7LFNRJ8hqOyMFZF9zvh+dGZoowNBCNbF +cSXkuxO06xinUlEmhPm8I3AFc9jcL6+FWrxfFsEQ1Z1fYifuT0P4NaBK8BfU57tK8jOApUqlZCtO +ETEeXZgxK5HresywXqiojDkKK+i2wjCETCnAg8akPRsk1+CoJjNYoOWqFwnNFp4fw8t+wKIQrDqx +2XqHG3CizK5/MUbfSYv69UrCy7qI2uSsZ0832D/6nb0BcjOxi3/IWLQhBismIubIJAsHPOc456sb +270q3K3XmP0yQ67Zg6cSjsK5e7oJ6IuZgyKnLpqh1/3cY/dtyfB2xDpT/su9hHalUUUHJReu12Vu +BvMikoTAs7Su9CJ7CNlhMKJVUfYmjiKPnYNcI/QyDeRUFRODF6Xurv2dFOcfMH679conNopgBawu +SjzkHf/+snfc/2jixg0WFiKuixsEp61AXk+8ATIsTxX1G7e3MxpPRn8nTiKGOdvTCwO+dlz/U4Pl +cKmyoLRLYZDUAFRhOzvTmtb9JRZcO+jIOLuNMI6TmaJsx/HXPVdhW1empSu1+9lP4/VuusatfHE9 +kPM2ei0xDF6A1mnzipLj678usvpo+twc7jWYsJDVrkbm0N/InGk/tNHhunD6Jb7EDlQYtx+kmQ9K +Ys8Apqn1wLaX61aRabXWCQH8R4QLtaP8wmkrRkQO5qT09s5EkCdxjF1cUeu09bVNSjTc2jJfdWF4 +6Knlikx+FSC2X3xlk8ZUOlT5BXRS1cPmFXEW8TioYb5JmrYPvpqXngaoO7DJDHJFylNMtZn3+awM +aYz1X1Vuhw78zHZ+6yVtakTzyba2IYUDabGPXHF9HWRTg5oQNG8RU6EB1XcJ6JKy0BiyAA1+bY1F +HBNVhQKmLm7d1ywKahO1XqJrlrn5YEB8TrodjbeB/wjd4Ds+w6OJolwwVPJqruHLxG+E0Qnclojo +OcuGjjox9IBJxdDZWFIvA8mHqyh5E14SufBLD/+C+OEFMyB+AVtNxAqCQjo9/BYj5g9BBSJjv4Bl +dnFI8RoJjQ3Bw2bTjBFHnHIilvZkz2GkFFttW/sulLsIJHw448MNCRk5iZXT4qv87H/q+oZQH762 +ZX6MLwPZlIDnef54SSdoRxRaZbkGySLdkzsnaPp+SsP0BLGDvkHgmzRKJmu6kMZQZ0SR+dcll/4H +UehYIr2r8XlsY+25KScDghrGShsTP1qjdiAh3aCm1eL6uPwVLSzYioQI3zL95JjTEjaWyg9cF/XG +bFHfw2Y/xaQPv0nFDXf2E0R8e6Z+AvtMATTjDWmTrQaOQiZJXjlz9L9/NZAWnV9oWVZDxP9HVjWn +vZ2sQBIlcym0ryMtUsJlIR6OCTNY5b0FS0JB6WZUA+Lod7Q9YkEBADlN0XHfg+zfKQIDgJ85yQkM +Z6zpqaJybjg5iOKAazDmMi9mCbojpwqH2YrLIz2WQAm73iCDW3xcDhgqiXyMOCtkFWVVl02vUV42 +GbY5PSFc4ovn/+Qs+wuLLAgfHllaL73R1lyHvXpjhPeyaEQ2zBZdfNiQlOIqAvOoNjI9TCg47/6M +No0oFei//zM3Izv53R+Rsd2OLWje6pUMLYT83iT3rN51jH/sSzAUFdWTcbSZzATxkhQBU9YVIHwn +iFwOg+FcR+Z2SRmdZgADMU7GuLyePTjwmXWrIWrAa9igOoUw4mNLin6FedqC3p5kYzLMUs8PLG8o +ARlnr6Q+w6jTbMbqbpIkCHoX930++mBCs1G4OX0MlvwKz6/78aZQjZnsFFIncWc8G5KujizUhWp6 +ldqVOgJKg8paruYpEb+lNCAOPf4F9HrJqRLW4FkAlg4Phmzg0wjSBxt1qUWIdC8La1lP5JWGIhN9 +gm4lYSeWZgIA8wE5TsI47mfc0EnGAXIy+dLNZwADDO6C9CjNDwsm2vcT2Uch2jIYMg8ZHSQkpX5N +DoAb05mLsXpezPfp2jx5y7RueSq8wFB4xsrzWsZbG3Js6rYAsFxZaFTwJQIcjYlPhQCtpmkzpE2i +qIUX+EESSghZ4DEqjA0nPirTREq0B4QdZn4SX4bzKHlKd+HW1fYZ+Y1hO/1Pu3AxYtjWl96eYXIS +yI2Q6DEkyKt5zLtjnW2I6TBr5oPGwJ72fCXYoDaQQpBy+YtZ8NHHWU4IfciY5Wpz3pafAzI9ZgNg +lga20rQKjswvqYSn8bTzBd15Xc9wsflbRiKvWyeKGO+h13CUez204FuyKl3SeQ3VyrsSVKtJLcPb +iuZqmrPDprP2IHqJe58sqOWNWEKdyiQoc4bhyDq/oG9QKNp7PsbjHgQi1eDhXq3DWUzUxE3tfmSf +dTglkPTxjbPlSYyVnHVKT+a97BRhWYqcYXcwOye3qP9xBaWFpr84qfFvxdJjjz+rrnWwfaMYRJAY +vTfz2fthmhiIaSCy9GDINllZYmqjqfEP7O79Ob8bPxVoqJoXaYIHoA601KZLz2I6YRMIRfjrRy7M +/3ysc8z/J85LfyjKez8it8orhQ74hUJJGrVZbqjXosPEdA1LJsMhGk1VNjEccs9E07BZqTtQ17IR +LPAv+EcXeKJlBKMObHRUMfjIUi2PQlSLZDW1rDwZYT0aoPWmZj4PLgaV3nf8B3cLbOZXNqF68QET +mxYK45lsSjbRTVuDzOPzGBMxuxRGAYCE5Ke3RgBkBCT9cGhJqcIH2g7+xDDLSU9t+HhlvJnyvff1 +3fT0PPII/TQbeXbZrrY8kzc8rqqH9QnMZ6lvtC3ND2FcRvcwNQyFYupUkSrH5PtQbksnnFH/EYcL +lrOczUtIDL1yDOM1/vlqH96+Q8XjvcLAP52CnnLKYIalugwx37EGRtgMPco8NYTI4Iutr2ZDma56 +DgZofeXkYaaOwLJ6Dhw+cQIV7HIrX1cay6zwEKlsblXc2yT5KLnqSnDiIjm9F+iKjWU8lfd3zDBF +Rom9BIgiqR/rQyVZ+kLYjxhTkYkiM/wXMIll51zLJm/JCgf3iIHzC2ThWqj4Gey2wAc6LPToX38f +YWkRmS3+MoVgAb4VB+DEpdYdBaknyEOLFrxvl/DIL1uQapnB1rLYwSnATssIdnTXkPotzrTZ2Xtq +Z3kVSaRQQzt4rhPfpjPLVbHbs0UefqF6+1/y8CjIYsjtpi5Od3rD/3mND+1TIQi/x9j/+g2HZ334 +8C93Bxw/A1pmVF3hqmals0V6gOdCbS5MJ/Pb//mnvqha+0FrhXxieren2tbJoGk9av5/1jEt4i/x +iacUMoph19PDFATiCoamtn+SQiYCHsvLlaABMEIcz5mby7iCPXWZAwkLolt4AhFw8LODOt2zOj4D +6eNimo1RyITnlzIbbPe0Cj7SjCkftUgM6onj9bdv4ibrao80LBxfs8atz5iWPE4B5s/INgaaTvhN +CGi4Z3PQY+s/0j3Fzb+DfkqZOANHMjAqT9MY6tM0vkqoMyfdtJW1DMOSTiav1NI7AnAxig/r8oR0 +XFkxpHjP6Y8OkleUXnNFcrqXQ7kO/IBSeCQ+4Q2SAmhK7x43a5aA2pJuQ6k9kz5s3gO6dQfty7LR +9LHHGMZpfcb5xpWSFi3vVjIwLpP7BLBXA2ENcsJ05Y2sGapfQa5ZJ6/4gJ70sSS/pT+m+OHUMnGD +rUu1ZZgVUWfvI6Fth/+HSt3cIx0J+SBOu9JBXez30rwh1f/V/keRun4hGo/oz1YPcRepX6DaHaIV +EISfT3Cr3+YIidZ1EF1Hd08BoYhnwHiJe2RPFaEl4aLkxiaulEYapriwhIh9Wiex2IIZO5RtXud/ +RvOReEMdouopmUcFGcUSAbVbEx1B6uvaj3/dFmFWg3SFKiLijP6D+BbQq0aHVOw0gfhP4PK5PAl2 +wHa4F7M0xWR1Br6TrNywzGnZgUQ0qcRGJJthuhJW2oscCK3xCU49kYTSS6LirzYcu5z3L0oe6pWn +9HGsfJnI3ZAywMZlY8iR/ranFEpieDzrlUbcRhcHhZLQhxgWICErAMDVeO4oNIjvgZDM6jBuhITd +beIg47vQagjCC525jNqG6FijFK/pYa5CksKFUl+o6OxRjazks0q/O23fMkKo7S972yrMCwg6HPZf +j2DO4zbZvn1P+tod1txFsQyfAY68xV1ZuQaxVgn/+8Je4X4w5HVUABbpaRm64rhYfVbQ7AmWErYS +X7bSHAx1oVHCKfhdSazEff8Unh/XoVo6L04Jj4AmUmAo3Hew1ySbUcTaHq7o5indja6Z4mNnHzv0 +3QBq03etOVDZCaP0AMyQjNGNXQ3ibvsnpZ7Gni+qQG4yq+lJuZTcAhpAr78IK6xkD+2dQ1PfgjxS +KFdpePlvQvEJLrAyhacnjz4MPeVCjFxySnKyoJRiebDEQ1qFGbh4as05k5Vap68cAK5J2shYrank +uQUSzyAPqhpRDLqc1abonpLY8zMYYUjUhx1RP3I6dcidjMUj+RVZ6WqWqOzUbqCKjTbjkZJFz6e8 +vVU8e84wV45siJl++C45CEpZPrYl1jZTzA0ujIkAQLEC9KRBonQ+PpspDf8Z3Dkpu/33yJ6y/fua +pPaIUC0MaNZJ6R5l+CAjLvCWNDVmb1tFa6MntHbVQ+so1V02DUn8ZtL2impmKGLAqoKSlcQgMl30 +0VUsiUIXQQJ56pBBbGJZLnZwTL4olTxcdf/c/2xE/BOq9dp00VksyndphhOBwsRKffom6qgRdPhC +xfBt3h7XvQ3Xios36ArVhSvGCeMc2TL5EDPrn/wmh1xA77YqV/VNdIoL/cuIgz+x8VJPdOwoC4yk +qwUUC/DcLSp7rwyOcVQhoPlEsuTgeNLiAA7Kr71HKERwDN/Om54wCAyExjOxxFRmEoEhS05kQw/u +cgsEAo2oihrgbAUJEMOCzHCNbvTv//mk6JknEaJiYuTOOZzvdMrDRAraeKel70ipQ1zk8iK8I2AX +3qgituA+QP+QlWVJpQG50rpQpTWb0sSaJlEewzJ2qlm473pBP8Y8ILM1MZGwVI46uheChh/gD3fX +AIRY6BihiUrXpagqcxe3s1C1aDSinfU17+hMXezPgP68sWwUlMXBA4iDGnlHVwMxkwGAo1UEUdjs +uLmvyvtn2cqsdnpob3P1EPZBnB6Md9dXC+UziV9QIzxRhCAOCyylc4gMsykU7S6iC+R6KxtXN4rj +wAOB2m2FZCfadA5LoRGvTGR9OLDL6Ra/1UUosK0u2XEa5l9vcmabTlrU1USpgvKOOTlYPtL6ADZk +YcFIgapdd3+tjxndKBRKu5GguhKyU/EN2GTVsn1sp4ORTZ1NtXZxd3BSNygHi8c67xvRXWmEiI6u +CtphxRr6x3ux8rWSVMZR61CdEQRZCKzW5216L32UWsC3Qo2c1f0PSLvcU7cqGcUEFwe1nsEcDfVH +y2XXI9OMs2XghfbzP0SI6yhpFYdNC7eH36Ej8v6qcWwWiYDhT1UN0/j5L6hbtuUq91ie1EpvvuQL +DEfZsam7n3wBlsj+BsBmYbTPIjY6rdF/HXF+NrNlQh9817JFFBUcDhSulFMQnxaG40B/ixHJVXGo +peD0NYWEwfcFHJP3l5KMUUXRxPFyYh3LS1OPvC98qfz4IwhqRU8JpESQ8bwgK7Os56FPPq3bVO2M +XuML24dWafEcDZI+zcnVp509Xz4qwLnK/yCFIDVBVo1D5XkhBM+xpCUjW+F23FHvh7mfxMqGplPj +dgeR6HTIMOJMSS9K3rnWuQPv+bfGcwGRg2WwuCRGAgmfmGrxd4/1bxwDECu12iWoFjWtQx78gtRY +dQ0SW90ex/oucH5h8WDcg16x3RfgA2cy4jFV+MpuwyNLAHugpUZ6KQObp0lJSeFqCAS2YN0V1LsU +GYzYaveEFCD646aNJXTJZhDCgw0U+/qkpVDFVeshwalTczpdbk6MJQKbZUbbWePe/61N78cww+m9 +dWsYbhNJBrAwctmined7lZ0WN1x8rQcEjYmGHebjLWlqCf2kN7A20AM1FsnfEZJcu/Sok2N+eBcK +rPs/2t9j2KChr4L9Ez/xQmSF4qgZmO/zgI9LICR5cURg0E2CrTX+kr9OtR07vtbp7UpXJJE0zHzB +/xe6y8ZqUyH4i6m55+gSqEfwfmE/76hWUgF7i/vOf061NBjHqEGCp1Kxh6wOboL22/JGD0PZrj6W +a9lYcW7ZfnLGjT8LEl28VhkJawKJ1M/Adynh/vOH/n8aQNBvdQrB0snr6ElToAyP8xkLrqABmyd/ +drAZwL3MwQF9BEOLOXTA5o3cJg+PCMPGP8JENKzzVGHb6005qM/slHWRwuGKTL5w7OrfDLvReR4c +t0epB+yVA7r/4rPpwie0/FQKbUksq0W25KG5TjKPNNg//vTnhJgUflm/4RYVgSFlGCFDKjY9P/3o +AYYRSkjYYgrge6lhJWIreXfJfeajFK4o7V2w0z8rwDtL+P1AQ8tlPPHvEJO/Rf7Ak/L+JEWjkIXb +s0YJW9ecg2Umu0Q4A1+PpiVLSvJPntC+I9IIuuCuhwVgIlpAqMWtzLZbvjgPso+LtX3v8g2+zY6v +r0j5NBvwkVrXUu5m3IYwd3OL8FS5ie4LwjSL2Jz3gIM0BekHgazMKvKQtSQHEUl2U4VFCh8hPFWI +xBVCPjTwTmGqUYS3EQPqS8OV0Us9qfsqARipg6rDWQBtqCwfYpHAAYoKLoIwgGkUMkeK6wRZR0FV +R5zETxNWUFRIwb1S0C0hzpywMt4zTNaIvAHkFLSTND3onpI/cYNgCpUnUaZvAvZGkHkjxsniiaUo +yZnClW3+wiqRlbeFpasWrywwLIj1bw23WdMw98OMClBqTnLnchoZ0+wpzS7SDIxmwUoJSCkOJ4Xw +ZwTdM2IhMCRGabYo4ckMUkRxZZnesIZyYTItJRQikRkdlByJ/QqISELw8OSoMx51zbqo4ZCGtd0G +/mpqRj6oFlMX2xjgWSoIJZ8vcWDYxJsdLyTTxahPAg5brMw+I8gCSEJf5jxa4c4qPC8V/prCMJy5 +yUdq/AwKgffEWk5sron9xzA0sE8j0uWC/SAJgR2xRrotZ/84EUdT4W0haHPUGIgc6WC+5PJ8QLwe +UsFDanZIzgsLAq2eXf1dx9zgjgBLb4Q0CL5uOJIfWtyvbbxCC6L+HqZsC6l/UaeQXS9Q183ikyEe +c4JypReBD+KnPVeP33h8PJgfbEI/TonF8c9dfw1Boy9ht69wjEQ09bK9Nc2ObI+VrTe7tqBkurPR +GL9hOQNRSPgjk0SgMuqsHB/HqU9lDpR1bFHimeq+pYa6SwZUiwge0tYPTccdzjFb94hW81hmopo1 +wdOY8a2ZSdjXuS/ET/te8mwVvTWHiuYIP0z2g0EUUZ1D368qkGgxVxeE6GDq26dt+Lh0Df09/K3W ++MrR3H4D+gm8D6C4/K29E1Qhp42bNpD/LDicRf6nVXSpNCfIO/ZH+/9uq1FYmQLkm2jthxYncD3O +K/qfQ8wPN5Zi6WuIqDydNWuYeArAOfQbKNYZgoRtk+v5IpDUxjyqSzVuvcALtZC/BKa1aTLzRPPN +Kdo41kYk0RxIe7dd3pRaWmsuMY5LIv2hsJtqZUpw/mopKx5BfNMdRZ8jHMsNBe3XfbyutLerVzD7 +/cPkQdYIolryvwhUjOdQGshdbE4AfkTcsFqpVyaGK8dAP0I7Xe8honRNcGumDP/tspoZFYZya3pQ +s/Y0qOYALLFLr/EuxbFve6B7k1LO2yy+2+7haIa4rbloJxXNQG0jhrapdR/LgnYP5Ex7Skrt/WAZ +IjdPK//9bWOsPftvFUDJAlSle2WBtJRG3CVy95IIy/wOGHhEW9Eg2HN1A4Gbt8fODzi6hyk9bLg+ +IdzDADJFhBC8+3DsRmA5zf1pyNMuIah/hVCLvM56bvaY2ROOe60lDXoP8B7XPPFkHI6O+xDyTNb+ +yz5S7plmHJ7LeDzxL06Na25fnf+ZoNwwDuOW48D8ap++whxqT4BDcOjHGF5JfQoennz9jVABJP0q +5ymQKC1wgMaSoD5Joh5J3iJJ9CHJmkdy40gQ3UaSe5GsK5IjRYLiRJJyiCQaOSTnUkhMdJDEFSQA +QTIbSOolkBABJDP/CKUfGfAjv/YRLH3kaOQjLnsEXfUIdx45WXkEmUcEPXLUIyTskUX4iDE+8uxl +rq/if1UDq8XBitSwcibWz1hPOlaEZEWDsqJe1rialXJnPQatLkgrdVoXUus71qrptTayVZBZ1DIL +aWbZmTXQrERHmv2GQhpuKljD7cCucCz3PZEuab4guJlPFA2ZamSp96ua6DUg4DwmDh/o0WtueNcB +EUAjfETx1PxTvTqA6emSweZj4FrAJMKxk6ceehx6F2+hGvaYQbUAjaEl2YptzagWMTYpQI1VM7H6 +BVY9CV23CqiV3KxkYqVVB/EFisONz99WP9LpX+g+2F/Z2AwOn2CtIAoygsPGgWe8CBp0ErJjxOJl +g09OVWEyfn+Z2BKfBdSV6lJJCjMMUzAt6eNJ1TRJd+G7BK8WjW8WLVd0s6JaFf1HRblI0SuKmh4V +dmSqAx9HoWIjzTCQXJRs/A5sbMGcKsQoMOZ4MyZKQUOCv4vQKwS0PtSdh0QOEKuhbotdZ2GeEvK2 +kYK1wZwEh4bznTktdRvDJpaPw1y4tznxqAqidFM8UAgUKSk0MBMdENPrg8r85zf25OOpZ+c7nTxA +TkmcL9SbTG0qr/nrNLlHUy/yZj72X4Yh7nfo/PzdrrqhNBFYAfYVoadAGJUg5EqNc4MiPgxKtPVY +1t3fJ+Ct+bHsFYNADWAEXGb9UAy8KDwZCGsS/Ao26lrCh3kPdyhcPnVdes8p3pDAuZd+3RrlIfMS +DfT/9j4gDjif/DpNm9A3+H+OPgky4vlJ2md78mOonGUz60eKpEFMUDzMwMjps9S1d/XHBKhJhTBv +fNMdMq/N1HWlX6K+KqboYYM5FFz0fOgepKtPmvo/69K9/xVgCLIMq0jNvYKjWYe4Bg75aFLffyzg +MV7uoUfrGWdm+wMzyhVbh8ZKLVWCVksP3RN0qUii2mi4pwBOZzjRXl4qH68Waz8WFGmHZp67SZNA +d3AsgYHwJVVE/VBjLmsIjnBKa8M5f4qprK8hRoZfvPdzQmKdSnG5rrobqukOYkxsaDSnXjVCunhL +SHO0GZFo1V5kR0M0+EZW4vhy7VXISOAt0t1xG5xL08JrwlVqcvtAarGmNxrDygH8LONE6/qkaIpM +KXZBX4Olupkhmcp3mZ5E+/d5lUJKr38NpfHf+ZyELcdFW9Nn83+QFiru/OP3GvoldSp1K7LDG2+W +FZP+DxwU2AJ0n6YNyfUFkz2Xy78rq7gc3uZDfEqx/ufK1sKz0Vs2aG3xINdvG63FyhX7+0GyxV+h +2P6on3/fETVMZIdB1g5vGk9fm+XxKaq5C9jQK9rT1lkykapPE6pcLrr3/KevpxbtI/i0xCSefyBd +VlZOvBv2qOLTLtdcfe+WxaDwWH8Icq16q7EfNvqEgZh3SLDiDxJV+MGP3Mw47j/L5u0PtUcCKh3u +VDCQZBAnHQAAxsDPRP4Bqri4vWyE/FOmlDQDb9Svhmyym+w+gtmyd8+MSAQAgAAAACBvB24EcQR9 +a2lmlp+8nr/vl82S02mjTamfuyOamd0m92Q1u0unXYiKrs/njcm02fRU0cz+0mejTrGxdeL//2Om +rsXL/TNDS57Nzp+sVhvZOfHtYTKrJSv2XF5b/nw8SyEyqg+1IJSMMiSAkDKUUYZILmgAlpUMEisK +kvzNZUvD51PvVb5VR77OS0bFN1wSJKmL2mgwgWUlI0NyQQwyypCHpyiwJtDJU45X35fBAwOSO6Qp +QIgLuiCNAisZqXEgS0quZDUYM2CAjOQwhsV0fFiHIJHIvzwo4XhQIAGFHkBDUcFBgWW1A0vGOBnO +hCVjExjXcTbCkrFO4oSwSCTupFxAPCghyYgRPMkMKScjKRZSQlIylsCAFAsDmiFNTjKmgg1egyMn +Fk4s5CQz4STCiYmaiFyQAZIEKBAPuBCRjGVosHEACgIMjg7/AvyD4i+o5BHJmAEFBjQYzAGrwWSK +hAeZtACSsYoJIBnLRACJUMDk/MEQ6icLAJDRQLnA4Co4QEjOgI6MtXQIgQEpLoRoYIWFRAZLDTpG +LDYdIws6MpbAJdNAc0EYNhoMCzQUrWnAcjFBg11QRh8NCRJMXGAwIBBQoHRAMRITFMukCbMDGygc +QFDYsMHiPkhQASEh4UCJlg8cHw3yYRf0GCAYKSgtKx8LMPzJI0PaaADAhmZQOqBUVFRUhFSMhMAC +LmwgiKAIuUBBERLBQssFChEKCpELovAgoicSNjxsYmKiwkPKBQEXJDIxcUETF4QAAYKLCASYtKx0 +aEgECy0XJITYSNnIaElLYqjgSZqAMCEBk4oKGBUXxCFFJUOFCichMCCmQEoFCBOKlJCM2YaRjptA +iZCx1uhQIAISjox56JXQsdAgIzhUFkRERRZERFwQCJCTlpVLiUcFBwpWVi5IgpOBgYQFGBWQkwg+ +IBkDDQ9myWCArCRAoTGhEgHDRYgBHRg2ISgcCGy0bC6s8VgPDckEjJAPCggZEpvMg3uAAcAdg86B +DcRYoPIgYxiFkOGRQMidgwQhxRJcXNAFXRYA4M5AiVPgAQN8aIAbAiIYoEQBCjxkXGAsPiyi4gIh +E8Iq3E2w4CEDXlCFDskdBRUJjWxQ4QAQDAmNyMgNmxEDMhQHLjhchw6cCNYBcbAYGAYcLjAMiHBA +oXPhcAdYSAlgUBggAQ4ZKiMHNnATooGQ6WhKTu6COhQkj4u4kJBwI0IKimHJg4KywqGSwcOEAZBF +GSJNAVQqMEAy1hIxgYA0oAMlA2E1mMzFh467oAQQVoNJkEY6DgOhYkHHyEXQ2OaDxSUHq8EQ4OBk +LdNAY8ACBDiBoflw2VhciQezOAcSG4+Mx2owCKSRjksg/kBieKwGYxRCNPCChQfmYil0M5ZCSIAU +AEAmFUjJGCYlRAWEAoEEvaALSlFZcEEXhKHzMeFUPrxBPsx7wQmK+XV82MUmBcX84gTlggbYpKBY +X8eHvXZ82F+coFhLiIUGDgQ2MCw48BwkDQg4gKPDBM2EhD4aLIHHT1xosAmP1WA+XGgBccLi4STl +ZIUDysnIh5MTCRwoIIhAAwYQKNAiErEy4kGEJUUkhIKDyAqKSMgFECciBkJIRFQuiHBEHnhYR6TA +SoRKCkjEBQ4gIDYEHKgIgQJiAIGVExAQJCMXQCJIOEZEQEYoRCQQ0YItHEJYUDpEQi6IiHiQcEEh +F0A6QiQshNCIFrQgorHCIKMSEeKRLgiCApMLAqFh0WAQgREQWuIxkgGBDcVdnOSKhYoHmAIoETL2 +IAJngGvBlZWUCJ0qIKuZ0KFAwuCxDig0DPwFYaDQlRUWA66kZaVDC5jcBVxQ0rJSEqHgUsFBAoE0 +YXZBKxMsGQMhwUKDsUzSXGBwGA8LDSaB5gKDQwgWGowE9ZDwaCngAxGQMBCCgwQCFCzIbAEU65BZ +sIL6YAj9gCmxwAABPfAcIRI+MGwsClDg4R4eCjiQmA1IxoYBDqSpKPjYuKAMxgGPSxBWg1nd0HEQ +VoOJsKEABeoTEzoelEQgQCkuOjJkpJRsPCi0BmMOUJrDQ4EENFJOQmDAiY0mYx8aFDA5DBgHq8Fg +WN7iGlA4WA0mwwGBxW0qEmwuYIxosHAxQYMpaBCUFQoWGgwC+mgwipMFAMgkwFgQExcaTEIBk0PQ +m7GJ24yt9IUJBx0aTLKQyGAgKFB8FDChIOHofBj4wLC5oAwGHHgOAhbsYyKAmLRsfDzQqMCAgA8h +MhgfClgyGAp0H7NRwZLBoABCowIDRRWJCwRdoCOE4yQMHEgAI0SFgjsQANjwIIOggwkmQbCoRDJ4 +CBKZselAsQuiEIKSkMPAOkiHBqPkQcqGljy4sGRwA1JAh+aCJAoU0GCEgkXEBQQFFBEhIRkHVERA ++I8EERUb+II00wAExgRHlmC6IKCAEXGxcEkHVmJAhiOCw8l1yKjQcSoKUBrgRMUAFxghFQ4+Llgw +XNAFXdAFWVCILLigDQcucICSIIJDE+AOHCw4cTARHxct6YIqTJCQkKOiOccigYED+9BwuMBY+UDy +nIMSAw8yPDojDzIfDCokUkJCC3IEu5ycm7W5k7kRpy8dt7FrMtbbT8ZdqZyO+w/ZULnrcJkbcfr+ +7Puvu9q2t9k3IWavK7NnQuxGG3UoREbtoZYKKyv8ZIhTiJiMvmnR0bPJc9dM/HRP2HzZaMnuvAwt +z1GnTNE36al2vk81ml7iTW3ya8z0NN+/TXh+54hT/pbH2+b1h+2mj5tDTmt+6JjWvJ/d1NyU/xjx +/p+Toadqn/thmhsn2cxNmdfcvw/7jdmQvzMTv1G4xG9+uulQMzHioH/2Laffa+ud5Sd8T/eX7g3/ +Otmfoab3y1XM9N7Iy8m+Oc+U071nwv9O+Zr0uzf5zD/pu6V9PvPcs/6035d3fKaW+FvPGhN/ow3f +51l64rdL/f3se2aspnrGn/rbL9PeLC35sb8z3uTfaKOvqb899TfiePJv9PTf5e9+Kc3RJhHNq3uT +0xxt+Cyped9bJjVHm9L9blR1/KTmpe+mtpb81tyczb3zcvVTzfqp62PzNde0a87mbe7mqHOuye7n +vp3+OBnnvf95Onxv1+TeiNPen3YxKeKZWRtySu+/0rdD5jQzDsCn15Ru+PmovvGr7z9jykvDhJrS +OxVxLg3dNP/vdpPbmJdbPfW80ZH1ny2ZN9qwWrbrd9dppuuut2bk3NZP3Y2ev/JUT92u3LmauBN3 +918fo/N/d/L+Pu9txv/HT+Zdiee+6NbW6I02ep/Quw5PW/35LKV3aWr1Rhts7MTe1e2ImZp8p+bt +Xbyp7R79ny+563maWWui/X9icvdzTX6ffJHt0xLTb3My+fL59D4h5uEe2m40GWbuYWIyvDUMQLUb +sust45v/v3YydE+2++rZ6MmRvz3frnPN/NJtI45ta9f3sTc+urJnOnqataXWLm61XT1Ojppozr/Z +qdESa6P3pzMe7tue2u4mTWxVt022z0U7S0yqjTiemt34Zohp1d3sMLF24d5b31lnau6Ute2H2nbG +mVwPLb3WOSb729nuU55cf/iq62bL6bXRxg67mZc5sS17sm206c5025r6tvtyuxZTXy1mPnWb+Oq8 +zRhTbqONlvurbubl/z9rytLfdN4208NtxMnlTbpdmJx2G21SO/G29napuvJ7Xtpa7O1L3NuoU53c +vbqM/Ydm/Mm3UYiMQiTpoCBBA3o2acnzWXed0UwzcSa/T7vb6dmT2ibO5uV+M+PE2Z74yp3VEu/s +P3E22pimPm83+1215OfZvLdpx2aLnM5fX7PL58fv9v9vTH6dbN3X/x8x+Z32b2qq++Wf55m9p8vO +30t+5/a765781M72NpuTX6km7vGaIW8bbqNt+nNMtpf7jLdm/wm1bSs5EXU7IdYi26fZY+rs6n7P +68eE2Gnx+jF702Gp+rv1mfkmzS5F/WX9/19Lhtn1+ciNmvouGz2lV5ksG20609c5evalWesmTUuW +vQmzESfP0V3NLC9xdjVarh/j7rX3udmmRZzi87vrbORe09RPXX+omG1mmMzcxnfJS399t//fMqE2 +2mRy9HLn79M040uoXd4bnpAt2RJxStuys+49db0l2nQ/+uWrY1cXn3Zj13EzFiN6lAsby4+rsRAb +680REcexWi2R89CRC61PFRcvmdWbX5/KRa7/R7TZ2sdFxPEtR0e0acfS8satRcTZwu3txfLkZazV +On1NxsJkRJvKZExGxKnE8kZE1OlLfMoXldP/DNHuMTvPljVxLYcVG7kOi5EXfxEFoHKRF9HDaIPJ +i5oWEeexF8tTEW3qWrEU63aV2bkRPZ3EDf6pYRoaHh7i4eEhIhoaGv79/f35+fn5+a3f/f3f3Rki +jqGh4Rm+4T53sjeyX9oh7jce+uEhoiGia+edn/fp+fn5+eXfbV79Zj5vppkl5rUm5k82xzSuxTz9 +z180rjwu1Ctt49a9T3qshzi8x7XHy6V8nZivK6+auw5tuvYK0/ZeXmXllbY+eOjpbGWe4Sa/ljVh +7Rdi6aMNImddXdqhjV0t6um/syrmoae4GRU5U+/4LZnVHjXV/gw98wvTu7E/s7K7rzdb/7930yPO +p1dmfTPzWrq7ptevW95vtmF7amoh2hRbfvPq/5bes2on2mxhLZ7ep2dxltud4Tmjo7MjzvMjDn+l +Lv+jJTu24ydxGz1paLOld3yHOHzHtdel186Y7I5TV9ohTv/+Iw7nuyNObWd3vXXdH+I4nz8fryPO +amniQ5ys7uwsbbTJRnfU+Xw0n401t7nTNqppI44fv3qxZ9rSVa/etGhTvKlfqreZZ6a1eq75t19s +6etW/fSUq58TcTgLbxVveb8WEW3qj//KmX+Pkf/rVF2vMY3fC2391BON2VKZU1MX+m5al99ts/u/ +FZridV8jDqPy/q4jjta66/P6W2nvKtob4682pr2/IU419malaqvjf31u9zPu/+8nLD1k5v90iDhZ +7ovFluX+eOmreJb1x7mK1qmF6632ylnLiziVmfz/bkmrsjFpJdqxo9lWt7sm96t7HvP1eVr0sKWv +m1miZ4vVEHvN3zQdrRlNVesvaaU2576ZKeLU77a2d2s1vqJimmVtGudffsr9ddxMxFlLWb3Y3lis +ncqnija1p/mM6KpmV7i2V753uaZmV2+p3z0VNV25d7Wy2X3a1ZZq7j2q2SGOo5c/5l8nZ8K7vNxF +xMlKrzz111uzLPTaTq/m4z818/ps1bXtlF6K/ImciTjl7cXX6dxqSb3cvPF+rbowd78VMSnaeOo6 +TF0zNa3V0mzcvTdDHdds+9ukyCSkUjfZJit/22TltpasTo97Efs/EzNZvbrqtmL2c6PdpgohRTFR +jDKETHg89Ps34+3Uqmdml35mlonL+b+Gto6+9Wpnfdb//HjGi7h3znac62kffR370Pg207+mfdBR +p/mQBt3OxNnP/DQ66tTqTqhAGtDzbOzwnm/Tc7sf/vM9N+LsL/v/+3On5nZ/s9TcfIht//3slb51 +YsZPXEV3Te3MjThrvP9tnazcaNPazt7x+o1HGtDzpqp3ZrjVqobbj7h9uJ1pu9FGe7W7cjMp9iLO +c3OKqPN2cl7P5a7uiCMCg8vlckEoG0w+CECFQ4hCWVFhCYGysqFQNph8QGExYVHx4MGkUAJgEmID +WkFJAAAmKwckSLCCQgCT59mggehEg4TkG2SQoJyoZOisqGg4oEHGZqJILmgAAFhhMXmgoVGGNGhw +NL67E6x6o05IvdU5I95a2b+iDRp1pVaXoKyMgg+daCAKzzt6UL9zj92hjlHorO+z/ozMfOjxzk46 +HtKgeahz6OGOnd9zHctDG/jpbCjT8Oihp9LP9zYe3Y7nvT+P9/f8oSefQr9+3u8MEXXs+VNEHf37 +8z/D66ChGt/t4/b2YGo+36edPjTES8TEQ1TEB/ETHfF4FXkvz3XvMeVFnYeRbx9W7wdVV/X53TT0 +5f1NbX4ONaUu6mAuevxz9bL1uUzk2z5FxEWdvH1s123x720Tl1PbNjHv2eJt3qLOhjKkAT3t70++ +6uq7mu3Zoo79uvY3o+crurfbK+p8d6fltdZzdEWd64f5knsnq/Y2/1pfGz+7ytysqMN7y4nb+mus +qOOqqru8ijqqaqq3uJqqroo6nYqpqaqqmWqqp4o6v8j5HCqmZipqn/p7ni7q6vOmiHZs2qfeaJqn +epqmn29qp6jzr2/Ke4+met+e/u/puvqd9veJOubPtrdzY6sn6mDzt3miDitv8z58jZ2oc7zOzpur +zLmvfGy7vaynuLmJOqidr+qZq6yJOpppmaiXmRgz0zMzM1MzUSfRLS2TMRsTdfYOMzHx0jIv01Kv +91It/+/yLlHHLL8t3ZIfPy01/3H/N8t3Xvx8RJ3zp9v9HVEn29O1n83PEXWsn+XudX6OWx/Zl60R +dR7ex46REXVWfRF1VDcfRtRJfTZZWxF1GGlAz5OqiDqJaEMZinD7eLtXt7eIU227x4vFvIue4ly0 +Rc+iDZYvFu9W4haarnLSRZzVrebSWvTUuy7aXG7dlW/l1RVv7e2izhWv32Yd89gx/z8Xn7u5k225 +T5+Ny5Wx9m7rdvU23aZbt7VN3H/t/NP75o//v8+Dumz4iqbI7696x9z9jjaP/OxpmqX5l7iod7a8 +izh+6nh/aa9oebdY5227erdoU32Fm1qoublc54q6qb+IU8pbubi4W27p9hZxbG170aaybW3rXhGn +LvW1X/sVPcX8HVe0UYaqqZdUVe20vPn0FG20eBm1unl5eVtZq1W1eKOpqKijuVllu99X1PpnRNVt +11Qz08tS1DxbzfLUrEwt/21HdMxE/ScVbTi1C1MxtVpbMRG1UVERB7VPq/kUcVa7TPnbNlerTc00 +y7R+TRFHa/e0UE9RB3Xjn+nfm/vr6enpyYpo2frJqbao+vmenZna2Ynr2PyZnJzp/u23ZsnJ+Zg4 +czkXOTkTZ+pEnM7O9NSpfPm61vmNx8uezZ7XnJ552f53uvm5mmqYn4izrNion57p6dmZOjs5c5M3 +XTM3eXNz8znzYdbSTJiJWbmsjYZZjImJNpWYnZ+YmYlYmmhT3ZpZyZiZurzfnJeZmZWZmZnl71np +3VqZlapPfW0m6tQXpoYn0VPe3NHUTV6ur7Jta6ujNuo6rq/nVvumdcRL+4go1O7jFqblU056/Cxm +zk7yRt0hDejZNKIjOiIu5qMlZiUGlIColDCL94mcc8wUEQAAAACzEQgAgCgoDkkGgxKt8hQABU9a +MjYsKB4uOEBKFo5EagxjgAHGIMQMZNAIIEIAlwPUj3R21ON+OoolweHHyxEec6PUXuHG3tm8FdzV +26/Dtr2iBDx65sB//To0b06XgPsn6vi0awiJ9c1TGfpNu24gg8GTTWQ2surHzQ8iOkVMRQ8bqc3T +dz23gbqUKb8Do4CprHXpqUS1sxNMxk+nDv2Kx0qUZ3JtEyH5MJyirueANVPNJ8uECmYbTQH4bQxe +wOgDhxfHWveLy5GRmr44vxTfqiUCt5VJVHopJlXVsc6W01qnKlfFWVT8AR9BSMtzKKHWSj2dvVF1 +NKyz89JH4IYLL8nSLYH3pNK+RYY56JNiRDxOnixB8yrPM5QUi+gVnkRCtTo+PX9DETY9CZC0XUiC +kreIZASFtoCr4J32RGKBI9lLDZ63TipQQHzBpq3plEnAOG1XcQ9MIQERItkIgxJDQlR3d2B55Kb/ +t18APJYV9Xx/NwyPpMOxTRRNl9NGgqGbkSPTA9XZeL10N8qDqn3YynJmIh8KwNQ2LKpYNRqklgbG +ia6KVcbeSbhen6LibqjfAdEW1bvFFc2ivYZg4NXmQGpiTyHtmU+MZkCUr0V0AgF/HunA+j0I6ruJ +jbKcB0censPyjpTD5Gy1fIzvSHOag/aNfXzEX0s+Sn7JUeCb1BQjvDCLJ2dDWPeD0o43x0Q7BfOZ +Bb6BRJ1CVs2n/J47TZLO4cP9qBNU/b6nj9joYwaxZ6N1NNIIkQ6x0mbFYwTjfgtDI5mUxJ5ikKqU +H1+UeGgoK38LK0nlvUFUcZh4lEpqmgNj/qWYtfkSn+htRkgJ73dzIyWzfIWvzMmLkCz745HiPGG3 +CXaYrTAvu4or9YUKZUJ9MaHly3wgSHyvxMeRpWAEluVAOexZrznE+N/VYcE/ElnxkRaWNaXHv4Kx +6mQHG6+rNKEU9qKXWzPRsB6NClLlwbA4kaw40Uk/Ls7xR+pLT3IFwEDdwC7mwD8SYCK1wGG+BQAH +NZRDVgyUlta3Ba53xA6hnzUJawnEAP7R2UM3iueapHx3K6Rdk+Jen8tqDJvk0KRJBz3pLBFeptTB +sUvS1W0fFhXgH+P8o31sPo0nG9NNDtQbKrC4GhrIYJM1FU9xHxtIC2trwHZxI0BluZVnkzRIJCyk +hw1wtBeUfyRDgFGQrNToI812UDMGj9D5mWQK5ZhJkw0eYf6/fTCCQZqQEoyMZs3SpuRN0WOdds8t +OZm9/K9o3y1vhlmB73pubqYNxk84aqjyEhQzeGaniQcJlJ1RcTQsda4xnpvRQBj5MJqPXSkmokqK +plZ2kQolm2XEzGnuAdfNr+703/Bpc4JZFgLcIlNiKDviKjsfWo76j8gHVolJ1m8oDCfEXpHka8E5 +ZCntq7tt7ep2abAE1WyZUb0o+YlQB0xIcNEzyci4VLdTQ4MqMHRv+iaIYoWiatIcKRlY2XO9kRdN +D8ccUtUQ3j1dLCO0XJM0vwF0TSRZ1Qq3c9gewS4m2oHctRRfBgZJyVqBbDRiNtjOFTVkRCkbV5J5 +c1pLGeFvfg6RLJlhyY4F5nLADUXSzQXr8H3Q9VCkRn/7Hr/E4+7S2iZZNjj7sA7ByQKBICKKzSLI +DGilpggEwMisO4JxEP+JWEpNj7gqLcvDDAuH/5mOr9lB4qlRvhH+tcko73QDQDdm8JUayhaoVCKk +OPuP4k9pYvcBjeK2azfFpCWz12o3Ew+CO0UoJlQ6cBI+Jqk+PCW1N1ro4kt7wpTzM48Aussc/9jg +nJRerE+uNdXyCRtlkvJdVRA+BcENsD2wo8jwynAlzGJ4bEpvSecKRvLdm8VOI0Y3IN3MBDPZujjm +uw9u9yBueVQWUYfcxtK3453kNFlDpTRETuhmPt5sGfxxgojOXK+v3Kt/EilqbGltFRDhV8IBvD4h +ukzWlD8vN8+iUppEgG+SObkpfOcmg3/PuhdyECl9CxM0ylhcDgcKR3MSAfwJgjPDLIDrUkSh7IWT +B4QFmpgQ/mTAiNT+lrIS1TsJT9REXnn3cjoOQpTrET8fujOFT0bYnYmP8SfVcNBjZgDIYumUdfK9 +ehPteW/zgydSoh2mzBI+QImGfy/M50RK8+dvEKn4u6VR2knuQuk16fJt4F6a+NpZ2kzhA4So+HfC +fJhIPBiBUQsCkdq3pVfuBtma7nSSQAcx1i4cDM8jsR1Lc7yPr0IocSZNWSFmoE6rHCUuW8rEOglr +jQn3ht2Lb3AAK27Hlom0uckyiAZHF47cpGPdCaqzJQuAu9R6BGPJyffqTWT75wj34k2J5p6wVIc7 +eSUeExFBCRCyIYtwjodSb0FxQT7/J2nUDPmSDxxonEHEbWMpiOskWyxM7EDUiR0bACW4m7ITZYS8 +WJwNOJScv0TrU0u5xkmAtumYhT/Hz+V3qNFVOwVebHzlqC/O2teUNMJBbpJVbQoQlWj6Y6kqA0NQ +MrdNRjbdh38VB1AihtIEKml2t+p/bp88vkFoGhMRNp0N5Ibah6WvF5CT08JvZTK49+S5cHb+IcW+ +uG1y2yw7Z1vti+xj7fdV+GuTHZ6b1xCgssPSBMzQSel/Tlzt8bm+FCrTL+BKS2V7RPw90Uw0MA7o +3vhIThCzsLcLNEXwB066QmqSGP/iCjLf7F8GXGppZMbwJwlZfWLT3BQ5yFmgYvyCoyWBgwpCBTxw +niQ28Bfc0qYAJS5LmYCnThrIzIlSqipJaQzLAklvIhDgyZzsUOckZCExV4sKAZqy3VI17ORbITWa +VPl6wJ9K+DZQ+OIYS3Rvhq26JtqfGPzmQpnQl75anAQCVqvCLpeE6m5poHaSB4aTxMMeIgIQdks/ ++ScUsIsEqLiqWZv6YQizJtuKoTCy31tQ9/gtlqZpLE6WEmabCeVeskyA72c+JwxA3SbR4sWCFffH +r232ZHsZOF7AjAFCr7ikQXhRIZ+37PC84MQvZs9C1pPeodwvMlyUsQ3+8+gTwwV3jLutCKswusxk +fyqVgWfXC5m+E2PB3k/AhjDuknLA39kEiPB7ujfZ3pha/TtQnrROfriTlH1R3XsTbggpbNMMgzjq +x5kcgMQEZXt/lyfb29Tb8zitBxMd9nm0pfxK98XtEJrYgQyE3upywb2Sv0CU9IiDO4Pt1TgSnBHW +WBlINSX75blpG8nVQqeE6WEnbERfv1ESGrDGuNnvCpq9dXzjomaip9A74M7nPlFIjQNA+k6Nd5jl +XJzEAQqMTjAsQQa5UgoTnsqdwahwJfJTBhPFASnrj4oJl2oUlg4tbN5CqgGEX995kx1s3elPYh9g +FY++ancnocDlYJIHeyJSB2iPiUWczGS3wGMmtyUmJfz+2kwIVzcJgpAo4AIyXVgkp9RXxYMmTVY3 +HaZpN2z0fvWJ6Eis9EL3XWlMdB8F2L2mkOS7KXYvusrZlw6rYjZh+yYZVer5uwCW61xNLcUx1P4b +Tk61LoTrIXoi03CyVieVpiNGxn4/hpjicwQ/obGhUOaT4mp20QICZPEsu+Zc4PPdZBveZFDdZbDt +i5WAkNh2uzoyMGFMhHhS4jUMvA45oa8PHyosKPbrk1Cj54QYfds35FOMP+KnJ35pDSaEB8pP9bEL +hb9x8Mpikl0NGIG9u1FA+ECYzIftsSyd/cI4bU3zbVoE76t3iYxvbFAdrA7BXZiOE/pZAfKlw4NB +gp3ZIgzlkqBH5VOeXrcgQMHQC3dCgxJo2kRYYRWe/CkPzZEwOBxMvrq2mjwGz9QqkV3uMTGMKRQD +ky4u5OosqOrCDbOuBZAw/3H+WMonLLI+RcjGE4dPOn9BmNFZOLSjCbODwzs6ocsjlQSsFSEPZOdF +MgXSJKWhO2hYwZoLs1BM06xs6T5lDxDIOsIP/mpM0WBeo28h/n2yoWZCVGx4viuz6mXDQFGo0U3o +e2HMW1ZEZ1/Od8kaknKZAlrhCOHWzHzXzoyM4vosG+km2npbW4dKPg7WAD7E0He2tzPosCeJhcYZ +C5xSZWLi6y5LhSDsRn+u2XfqmIlLY3WaJ5qFjd76K1Jf2l4tXfiFNXV1riEzCpu+ExLt7UoX8ggE +v26GqbDKDNi9d5hsOYKC4/duWVH8kUsBm48/diazKaMT0WzZy3NX5eE/MXNym+1vTpBKd5r0B5c7 +W7pvwKq/xEw4cKJQ3vXUIEjP+pbZYQWIpOW9QDJ+vV/knZRN7Q2CHswBhu2rR1/WfuBSHBqN16H0 +U5xrgRGA1s9cYJecPKv6pVevINWmU+1mXsNzV5hWdjI0cwM4l1YNPbgE5FLhECtS20WoBmYJJEjw +IAyjG6MuKLwJzB0usC9rcyt8MY46WrpDeeU2hHC8EKE7mJfiOodw0Il3koGLS3ipoEZOHCVztQO6 +cDe+4PQvVva6V/C9LFPtdxYDJRAktl+5kDN19j4kEJlrVTKGAgCB7YUaxndaCx62Ccq0MSRKsVkI +G38uSVUSz/hxtTv83QguBSkR2s3y6x5pkVYSq93rUDIHVY5cIGSN+we07YgkrFrDb3aMFp9Cw1Ez +MxnbZaHhg9RpUFhhby6YSeWZgJonBEkVgkIKZWkiQdux+vTao+q0WRtL3VykVp/lWXWfiL0R/K7/ +QEtD7p29NU5AmruG7X1wQNGTYSRzuVS20is5uMSwNVJQmcDO5jSTL6ug0bBf9FxI8/HDgP1AGlR/ +3oaZIqZidGYBqrkH7QKqPAEh0btl+jzYn90DNTNXbL/TrnM3LuN75vwnbZecsrI9ueIsT4soYiU8 +SOLJb+rqQNlSXUk2wL+pnNBKI9oeZqvQ4PCRGKASBLx2XnllXcfz3gj5xP6T1MP864iq6oVJXBRO +QJ6ottHfojwdDe+rEMG+oOwBfu/HfPVJ4kVCHMw3GBxc58YLy4KXE0b30kqY6XRHODMV/SQFEYcU +fS7G8hQiKhmzCggkdCa16V4jV6c3MO/R/LiKXHXxWB2LrOzBGgyLmy1i4g3/4WKkiJRdb+KCbEXC +ULXJEpyhzdWZMjXTrgZ6XkqVuXvi1RUFtqmSOyXfy1LUg0Dxk3AixLgcnboAsFGfMUW5+Ge71Qqj +CIxyRHQjzzwvCGDxoIGoqhPm5Snz6lKcR3QPVCScOOuOCGGcGOVWrGiEbvv91oA/DZG6H5+fzK8p +73VWsM7BxPZxPxS8Jge6y7Yk/uZ9Ap1lWqGQQN/aYKfVJnEkGWY5Ogigprj21xYFlBDMf7RIvoa7 +gJ9C/c7/ZkAwBfsxt097xkRDDWAjzDRCOmrAQbcIwkQDQjvaMC6fo1vInmvrAQSbcVbOB7UUvrxQ +VLpw1DkxHkz6gGshKZ2ReZDnORXtQiRsVOPA4sTSxBCcClHZmSywa1LgYcGlqn5dfbJGZZRbW5Gr +S7eLv82A/I8B+amJP1ZtgwWbgTwRWon9cixlZduMB1dyMFOp78HjnRlYoJ4rrPKWHIvfoeWuEN97 +uxAY2nAlhYYN3a2/bS7xWszHxiuFokZ/AcYa/q/FBTZ9lV97+eGXzMrVAaQ5P82uWmUVUH9+rMmS +fYXbNeImKnrXAI8vJlyZcRKRx06HeTiNrgcRfjHxyIcDsYjAQ2ai1T5xAxC0xRr0doc28FQZIKUv +qbSDxA4qkZN0b72WspZFztwdwmY5A3jihYiqBLiR/6O06HfwePLNjr4305R81pgRVLymLGV2vRsZ +ZK9sCzXXAFfQg4TSHQTbKkRmLjEX6aQg0iX6oah6bjwfCGw9es7YiTsZ8vd+cSqEC1LhIEiFNFb1 +C0VWWc0HR8Z+ZOh9h8h+HVjDfQupJu0wvltV9yRasERIChQZh4I7kjgAb2PPdf5jGZviRRDbFwED +kYbkDxxbnCw/NbE9mJU+nxKcx+hmo8xLhj1iroh8nVjw4wCxsr8kKrUp6hVmPgQUsFu3IO8uZFKb +dwrQCM0tW5ZjKzdtUcIC4mn1RaN5wQR6/JtcfOawcHs0rH9RHjWZvIf7/ssjMuosxLh7BeU1Ljo0 +ayEQYlpgpDJkYBRiFLd9UwT6lEIMXSalvRADlQtTwcAnjzvpZRnivACLEBN6KUwSeMtQ7QP1DTHf +6a/dJiK2RNcexBjRJLkImEPMnxJNWbsrxCiLYsX7pHchBj5LfZk/7CEmYbT+hvWlDrrlZ1G10q0l +Ey6xduHAwPVbmzYQE6kNSAAxglmgKHwlKrWFwmrjdjD7VtHdUhuIgXpb/VgZiBnx8GECP5geJq9y +1WH+JzAO8E/ZuH6JTGQ5jP8WWZBii+4w28VetILDCCV51TYHc9ybdEh1UQPIruEw6nVIxo8Jtzd1 +kVSjsrKA1fbIGLVJxN4wO0LiwIKTdcN05lh4YHcGDyPLQaE+kDbujEgW7te431hP8w3TGzfHYXqj +hhmV53xzwy03FJF6Sh4apqorsNrkEMO8ZGmY4XmJ68JpLNS4iLu7IPnlmm/kdEQSJO4iNqQMwauL +DzU0SsP4tqZKuSHiTnpTqfwCMH0nTSOWGaall1a2UlEa5lAkSDC7zl56uoqqQCRwKfy/7/zELZxZ +wjYxWEPjd2NQZid362NrKFzmNLkwzOZE6ZQ780oadBJTvzFM0YQmC0s7MuO/T3HKs7CKgKtgzlSs +b35d73T97bjdn5io2aiAzpWCgSwCrjdMG18jFkgpXMHex2wt4mveS3bQtlh7CM3wvz666VrhZ0MQ +xBY+aBf2dNoB8YUm2A0DFdLRMHdebahe2S8OuA07VUC8TxdajE5105Kdo3nNgrmQef0f1MtJwK+P +54y8XMrmDkbOWKLdOh9HgXoDlZ1coAjIgCy2vk7vNdPC2gLVGMdLLAU4YpEGN3cX/MzUhM2sV238 +i/PhlzCFdsVGEMJOCwNdTY2UpOwA5Wg1I0Hw1eG5sKbg38SbpvMdmPtxniClv1KAI5ryxX3PMUQK +syNf8o6UgbWlXsMS9wWb7QyZAZIiYMl21oXhjC4IirxoMS2Eg455PIHheZlUb8QeNAhi4rxusXXM +8JKl2y/g0FELhkkOLLVAedtDPPli9ldvHQLfdxjQEFHX3L9T/+DhuElaK2B8RHLFwag2kNnNmvi5 +ZoOeRCrXG8AxB6NH5G6AVqnkWNd3vI3qNYiZq965t1WwDPxvKGQOavUvJEWND21EA62F6V44kwWg +RyVv4VlHR6gnRkomlADHGSSSFq08EAOTdYOd7+v+y6HS9YH7x6SpTDm+2cAPOp11kKbd5DZG/2BP +eaij3cKE8kTTAwI6UnWZANYWfJGci/PHnEKjXrO9qDIiFGC/+NdqCAr0eY8p16XOjc0hn78cAB9v +o9WJJUdNGmuR1PbL8ucAK06mQpb/erwha5/j8OHcytqC2iuvcl5o+wE9UiOWeIX3Mqopl+t8e8gX +lKDo36HbYK42Z/mF3iKjvzmcJVQQuFwwcxTesTOpadvdnJA7Bkypvx+sfypd5FkM5egODm5xnWfl +mGBwx8xjTIQP19GTAWTlZMUopHyfELsKBI1cMd7EBltgGxe4wBUUl1AczfGMIr6nBqGAmAOJzT4U +wxHVx4M42uRY6RAfK+1PXpNGvB4Ru2APkuD3iwuGW2zrwiYSaELLYhQdd4GMttj41vjXtxnNqhWZ +ePl0dNztyasfYgYYaAgS+8JtR630ylXS/JBq1IrEonySWqFc8Ryer+s49VQT+8lIhru4N97c6JA0 +WwCUbu9cUI7HNhErhwXRTdX+FlH0adGuQKva73h24Nb8pmJnznlqrkRLzzuIybCN1Ao9Q41Sojwj +Me0imi3Ga6EkurJI12Kp+81U+HR+LhLJKNNyjUEdbblWLZ6Bgxvykkeyei0VMCl8SqMYPHhAQL9y +VBsNALKSZGanDEd1Mn47O91PNaFUzROwW1hWkxhQ6beB9wIl1x25tP3PdHO1aBexoG6zZwCgqYho +GIv3HsOiGU8aSkQY62JlX0n6lfHNVArnkgCaREXIGuxw7+aBKE+IA28ez7rSyLJl4D+Li0qquqPR +OitSxzLaScmaG7+OPj/vJFCmhEDmokt5a05ZRGlukOGI8SYQRJMLXa5XyOJTZB6Cdp0hHAVTHkAQ +tk5eUHqD1t8D2zU04P75hADIkNTpMgSK0kZwmO5oEjQ8W58Q5ahB2NsmldAeyEgm/2Xx5WE5IErp +g7f8gwSeTCcOjKno5oqQNonpaCsG4m2/7tGCrRmBUcYkXgtGJlDCf5ncXgn0O7ogOEGdUiQXEAOp +lYoUOevIgTOjvv5KK6bqj7cJIohzBD15VeXs79hUrUejZ/b/oAGn8mKhMqvUyJIT8rqIDIsUZpNm +pQWA7kCNlGmFBvtZSS0HWs/yvsPLPhhLlrc/jNwHdntU3DXSgaWMS1P2vDJVIyzV0Gk2ycN/kvOl +VPXw0Pv1KElwFBDTUl3WUl8N6dlrpoqA+eRzb7AjCrcGEClE9xTXsfgBIjBvpRoRhQGiLuGLhZpB +gLB7LQEFiFPVoLfi2Y13HiB8/5DtHeX9QlR7ldjEA4TJSm4C4koM6ACIHAt4/2F6SbAizvwPvhqo +n2Jt/EO2Jcl/O+0fNi6sEkfDfTd8lQvaoYFRF06a1lGT8ljTDGzMi4GiS8/dy9/SkSaVbgX4w/6h +f2jcQAJEHyQoIKAdQDhvJRDdr/DnCjh/o6G9QKy3Hej/QEhsEgnFjjsQVns7ohKIu8sDCFRBxp/b +/0OngTDWDIQGIrdln8nbtXV97iQMkJVhCgYiYZyN5HahFojClbqKQEj39VK/EL7O2U4ORKge5Vj6 +EO1AkIbSlmYcp1SXoQMRIOhmcfLGvCM7Xzq76QAOhK+8bKGYSfSuk2sNBGNb0T5BtCNFc1qaK8QV +PGe6B68IouGS9vpAGHuKLgeIDw5Z1V84EJhVbPWYFo5cuWFlHuu7+OUpOQPxZE8NQeA1y6kGGiER +CxSdt7KUBRDM04l4RaJSBAi/XQZTXxH5ybZXEV7zIYmKkDQS1uTBJIurjiRKAH+loNmuBepEGocj +3bLb+UaSRGgJqUoSvW3W6FpKceOWK6w6JGhrs5Kw5ORdEEt35Mzf+jm7TzhegrzLHyUjTfFkF/YD +RmvgE2vcOLvW6zEbwXkxsugxiBbmp6Avhcyu2UDt1TcPCJhxy9e1wvOFucv9a7LAB4U86U1Jedl8 +SeFPWrzSmuZQP2TwKE/WUkmY1nFLvlLjZwQz+Hsr7j77cx5dW3AzEOE0FGlQQZPvWGizA5xvPGzy +kBB2X/cnYZeFFkgZzEw1pAjwGU1+IG2eCvifjD37i4jx2SLgWQcF8EiVj3GIPI2Knxcpg2YnA8uy +ei6DQgGBw2fYoYWTWHZR2B4TJibbO6hw67E3Olr2/I4RQU760IJqngyteNbu5xhdPbOkDiARTTHI +0HuLHo3V/LK5tgcA6xTo3iHsW7oHtJtsuYwHklCMFvBWUaD13GAcS7NnUmL/MCw6N0MRU7Tno+gn +WRRxNCdmsg5OsJHlBYReax77JWUqMl+qMDu/LDqOcfA8xOMHcqhA2zAwJn9uadFR5vEl6CXhSAE4 +IUSgNU/8GG47qQO/iMqUOHEMsjLySPVDabsrX2yR5VbWY3kopzcIP5eTfEj1kcnoHZOHCMstBigD +C5lxvFzgWicIxrRJXY6IowkNREUkomUAfOpASN45TbZpl6zLqCC6RpQM/IA2UzMFIlBC3WIcDMjU +6wCfhwE4tk6QeVvl8Tb/FJYAvvS45mmKI3je9eV8BtDhbR7FlYYrCKaihlNKH+NZwh7gWzQZ/vmh +DqPeNBFkT7w9qCftv1doJPCMaDEYW7cYhBGf/HmD3suzjaO0hoM4JrJcjxJ7GGyqpStFM/JGFy41 +b6KfqKQn0EWK/FREZ/EpIoGjlnBgk0uCypHIiKt5NMWD0lXLNd8J6GT9BLim7H+hMzsTZQPJDXVH +cy/xE8/uhV1EbxQap3UqVkjuyYhbHbSWOmoVboumpkk9nGHee6SELKorIcw1anD7fq4Ih3gvkJ2T +O9F+Ccpn2aUmoxEu0joSiX8nXOE8tMShUerJmGyCR8LxZ7eKvF+0ik7wOmUbxajoJMXp3W30DdY1 +pJAGr6mrOryX8B41ZQz2c8APXP86LRSrNxpgJ04uBn1YWklailH5oIvr3wTmJhKcwLqciDZmHGVq +4DWFjR4tpqg7P/VhO9Hs7N1g0HmW932TSKZflnEnjdQK7GwKHo0v6jHJZaZSioHO8ISg2FX2d85J +X29mq86AT07VIgA5L6wpY73HNifEWOZJ8I71uAzVGD0ygDjBOXjY2MYPmWueCun0EVenv6p6YF/o +/evCKHekWzr2nFjFdyeRlZKhmvkNna5kVROscLHktOeYWVDTRqKigNI+3Ei0mKwTIPisMvDTlkg7 +oYyCPBXk3uQAf+biC56sWaCaw+dVv4LLJo6OyNtiYCKR6ZdmteCD/OxQpKNWrX2fncPmTTjLh9OE +O8FGrrNW2BaMstgMCh8qg1f+L67H3qRfyEXtRsRBK7sdCVfXVoxIKneQ1RaZaqblxl3oFB3p/bEM +NDFMU75m8PrywtuMRZLojgc8X6wg5RNRqMLtrzSgX3oRk4epIenUZHct1gKuDCUnuR+SzIFnbd5B +tOqPQNdMY0Baoz0qe4K3gSI9d/Ww1H/tQ/j8J4KGhYd2Y+pqp04m4IZuyo3pdgLuVrAphRXU2wxX +/1Oglpuqys+ZtsTnGCBvXXtRqAOYqB57b9aJJik5R6+2rsiGYlF6+b2aoGDA+FF3i3aAvCNaRAqQ +8ZKyB6ijVHn/AHTUaKLcZiGF/y8o+38k+Q39n78a5CddfUBIRMucBSl1jj0rpzGBgi4gT1VFG+vK +g16lC0YBeZDBukIMjerhTx53dqJLtzWxgcmRyACMR0WWSY4pzQO3x56S7irvSUIK4cr8j1leoD1n +CHOM6slgVwNZ6SvENTAc7udzgjtchnw9fGNA2eCgXYeuIfKhowkDbPqChJABLzLq7oxa/oNn45pK +rAFkJCtaTfvi83HJyoVqRE6eZURPAtWd0h5hACHeP3B9pAXbVOGl2TJkzlhAayiqRYtIp7RvqwLU +PLXTGpQ4Ex4gtjkxk8N9BuwST6MAP/7ee8R8ESgoXYCUdMHHhhgFmbBmRaPpkU/K5/5mXykbGBW/ +NpryOYrWsQGg7oxl2FxY9yMig8RezzW1YE45lThfl5BCSlgq/2rTsbEE8pHQAhSF7KYV0pZJMLiC +j9I+H5/hINkvCxXArQX90sIGxrjlZTKxcp8oQnnELuSxH8W7gyMSoFoGJWw5N8Z7zyzVBasg3T49 +3jnn7Ei9HojSXmMGxDSedkLJ/zuwTfG6nhaE4eH7x9yvwh90oz1vYZU94iWiSqeiI3r3CXHWqPfr +6GU366m0DZfQ4TQOkz2kbTRCENCsidN/M16Wjx0VuIuL3GFZYIoCUkS71R//lhtkZCdtlzmkFHQq +k8gMrPzMjOg4jObAiI2Cw3fvMkV0hj6WdGjT2hBB5AQFi9LjO2gFUgg1xpI1vvYcevmapv2fF5hy +aagztlvkg6jo03Bfm+Yj8dEqsoyqfzvcUAyO2eCMtS9TYOgDPp+N2jHaWNQ+NF+xBcuFauOPA8YZ ++hQ9WwS+xnZ+hqitnLirX1k1hJMlCpEe/xJTTZs5PNKfGw9BP2mMO/HFMt9UuDlGtSFpsZlqCHCv +Kxv8LwuvA3u1hnCywiA+2J3uAcXV1OV0Tz5MvzBalUh2jcZCteikULpeFDJckNMjuIFGbOZX3O8Z +eT7O8yWxcdZyJWyGJ5s2ATs33ilKHonBPARo2RwpAI6ylifPudjDx7BLzviKBgUcP6pbcymnCTg8 +D9llc1iAZv0HoTDbruNTAH5oHqfOiCrWtiXjdbhiGsPCQ6WpjlX1RuUqHOw1lQTnDn56VCOIufDx +QpgKCchiLLg1TvlNqNg9MNTiipyUinhjPYMpGoVlJKX2uooIOG1tckd6MzTdo67luE2p2wygGc+s +MFR6hVYH2wwuhisDEDiCPbCkXZmEZ8ZVoSwI8BSzy9EgxDrnEIG9gsMbGx4BnNfiOYM+euzvQtRH +FwB5mTIiYIFCnKrW07ESYMpxmir93JPTPOggEbCFGvGAOf3HsvcZp8Zci6GK0It8wd2x3RnB87la +v5JZ1gGPIs96P6tFZt4HIVk1XDUU4dBQCQVJqRK6aLjEGQxYCs3OSWTD8W/qJsVNbWmwWrojUR1u +Ne/422XlAnutUoldHJ8rBTVslShvdIVmaT2yTu06DsIpWAlUyPnnZK2CG8yNmpx+BmXzRJppd8CH +RplZwmTaA1GeiUbxPjiesZS5zAmKF/XOZHCGyt770c6JoolFs/Vm7v8vyvgxepeKqLgIjhxCSFwy +tMakzHI2iKeY01aJDDc1/MIu6IS9CcaTn+VRLTfx6eCNJk8Hqx2XcM+9uCRXIkku+RUSEO2ZeVO3 +IJchWsUMbxhef30Ey4SSFVsoAkuopDCjZgo1kPwvKqCoNmSRhJmQOsFq4nEMBAqFj4n1wMicb8BX +fRflqz0apnRkIuzihcaHxXnCfkyYGXTkJY5lIO3yYkyc60Jq5EfVxMYGcVhrSP8P6XWiAzH8UmMi +CrPJBmtSR/8Fr7D0HDKJse6zCgw4Qh2e0BBbyMlIcz09xrACB0AK4SkM9aQatlWBrp1fOPUw/CQU +S8Jm1Kk1+FzHBcpr5+syPWWSO3+/WMqbb/Nw/Jeqi1fQ0tZPJC0GnNAXGxPU2xXidKf4EtMR8w// +tjNRvc91IIKMwGmbCTkcFvkuhRo/wpV4mKS3FbWzJM6M9aYlWaC9i4QVjgF2Rkq2meVnkuGHJCOR +3m9/mzmA9gmUQzC0LYftCCbnWcTlQ011Fin9WcWKuI+DuFEpmj3Pyk4OKRVirQ9oN4vOQQlA9Nxz +umn0yH6UarC8E7CZYEYyP4FcRT/nZOHGonukXzOh1BJNHx16npfEITPz4md81fRx4SUcCWCWVZqi +uN4jl0lo5UuNJZi6epDT7X1eWlkTTXrQOYlsCWIpK1E9KSZqHvIlh2hejzXVjBUONq76P7p1acgn +3ZxH+87Jt+QTXeAeilNBTpNmnQfbOjjOi/YVlqZtbP0J4tBzBemKoBcJ/w41FU4dsHa+mYh6FyxE +JnGSiaHvchUpeeJ26aaa0Y+070fBc7hXUfAyPpVwtt9It6NnT0qqzXOBEgQdN5Zyr70DGEemlFea ++4X1p9ZnvgpdH4lpXOWJ5HcueO65pmxjTt7Ekb9NreFW5QtNUH08kwRgrsmryzpao1nkFEBw5CtE +NMJ6Kn1n8uLA9XOaGXb7/mamoqAZot/l3+vutVpWTBgninkB7V7viKyrc9AidEQAVQTMVxTRwpkP +ExdLHc+NXaG24+FwSUZCX1n9ynXob48WD1wUZNGpnPdo1P0F2dAxRbuL//3KnjPwG+0bgWh/BGVN +/o9tw+MgQuZ6PAiPYg28csULsKE1Ewkjr1qR0qbjuR1LOvITggQpKu1CVYYCTgxEcfbRbn2oQQZR +RO6lL8qb5gU9r1rvwPmdsZk2qrRXyfBJuvNQHnSdszKfi3spG6pf9ijMMmDKWI9WtTNRz79A39K2 ++jNoUWE32p8Zqh9pX1RLkvyO+YrtPCWESS9e/vR4N2xWjb0QBbcTDCquLEt+uaJ+eDcDQAn8oR4e +dXW/wQ6aKLUd9r6z0400/tyShE0s/BZA3LzYOS0x8tGx3A0i96SIrh9H/VG3ARWNkWExQCnB/dZ0 +8VUasIoqTtVGpSfL3SKI12nR+PR6+AjhvkCS7glVeog9HzS+g7BPjq5tXb/tdY+icAS5JqY9fWBZ +Tu3vaYE+e4MJpEwu3oOJtCfJYkWRuK7ZG1SFWAUg+2hZTvguxRWYh/B7vahxDuErSAATEXfM792s +BdiSH9wgk8LJAL2hiXeSHF46W9fqdKUgKvW/VaLS0x2mkvwCDkI/Uan1GNQQqdW5unNCcyarYHmv +2MPwsCQErN7rf4lgSv5QAwIzuco4epqkXBulFBCqtxmrT4RAXSl5bUs8bImZ0sEeispxukQppysU +ZDx+vbQRJOfo9xzkkSNMCMmw4AMVU2B2yDMZYB4cu9FFB/T8hug3F8KBCsT5KexDnZX7wVgk4rFu +z79cpJSSOiLBMufg1Mk7oHNyPnABhF0ZnbBUhvY0j878jYWu9Ot44AL4q4Cui6TyjjY/0iBX7hhx +KkcdWKO96xLxchP2cxyskMu0ghhds26IrnAMXGgcpANWQHSLDMh1GAJSXyAzH3qL9TELjNAWq3X4 +bSdWk2eL1enOEIrky4vVmlj70y/W7rbjkDawpI+H2/TH3vBkAs31G84TEqNsam/BG6ZZBFlho0/e +l9IW1j2Sjbok0C0sWpl9UQhKnjKIgcuO132C5yQb2usuRAFPO/a8AKbQ2HtAfpx1ZsUW8sZh4fo9 +nKvI1rvySiDE6v6/fqtG+OtjfanHoSylXFgdwZpigK7GcDRsmMYF47mXiiY2Wy2POkDidHQVyz8M +SMrgSi5GdFGwBkkiasFYwE3pfuYgLcLhzJYs4mc70Y5VwXf3gh+PzaB+DRrkXwU82Ysfj2jlumsw +uokEdKfMZevI0YfLbHZLtEIGcNyx+2vEBJkZSlhd1cCEup9FsQAtG8IzDTJW5VLddrsUIQ4wGkqW +E+SggHpnA3Lb4hLXrz3B8d3e/Ocac3DeE/oP8zCbBbcQ5YaBb2wTbiSN3kSGjxwEmxzYwlRLrFJA +hjGg6AMkQwWEawTQFHgTp1lqvZtWQZIct0+z1a87TWYK2XXUGWfplLGZUSphAyt+Em3Sa7ygVk5x +VT+UnOvHHVKhV7nAOgfjKwc0m+BNT1s+PpCPW+Xp0dBdhyuXAu/h4DlnBtCKq3SAFv7Ym1Ms+KiY +KyqD5gwn2DBFkkg/KshUkkxm/mC3xNd7Z6bM2+W7M325G5KVMDdMveSiFJAgvjJT4jKJvfnFoY6D +YkP0iJaYywQlm8InMPrby96ZDDzB5pRgU0gvE2rvlMzA6PxNG3YjtgIlXGSi7zRUqxlk4oSIScng +cJPSP7w4+L5jpk4bY+Fvgmjllw4Lssu+iFY+uMxcM7j461IyEgOlnbyLwQBKjGEJCJQ/KynVDap2 +Ua4uBhANVMQ8vNNQMMVtQV0NUZqHraz4u0IVe6cQaYQqGOfa69/R9kcHQCHm54T1rPfODFHupLoe +4I8Od08xCNF1wc+aDtPqAkPIDN4lu4gF3PFy160zaETHsoNX3dZgX7sothJAJIAAQVDcqHGyK7rD +nhB0Br6xanKFJ493hKeNUo3PixO3ZIqM3f6wpJecWJDYjYwdhuecjilDEcrFln+u4EGl1BM7R/V6 +Q3KsBTtKED8Pmy2pMBAvbyBhuRW+mNuAVIXozyqgV5x/joVweoilN+Y2ABqbNuNMrAdeBdQlrrZO +mmcRyEOuGOBE7W7Fqm8fIxycA5S+WXeRUVhNQcUCBIsMFQfYyUEGsSUyzSuGbOYgg9gWWcNsHCma +QcbYEZnmWYQGWcSOyMqRhdCBjKZGbNrdfer9/7ai00WJcsMYrEYnXteOH72BipfdUnKkl5uWMQGW +MolSxWsc7iyCMdGqTBql4m0gdjmNCSaUqZQqXsgudAkyJuMu065VvBYiksOYemKZgrKKlxxjggll +KlXxwn32Ehi9LpiWvQGfJZJbUqAr3v4oQXGHy50nIRWlwal4cSwk6Fk+CakoDa7ixUs1sFr5pJ4o +6eyKt/60HXbi1WWyFa9E3jiWTwoVpYZb8Yb4jLOJ11QokoyOJkoHUvGuABZX8urMzRPvrcLNIvMZ +foSfvkwA9+kQg5XOCcEaje2WHeVDvxIP2RUHnFBW3089++TNltbngsXZY65MajZkY1alMV9Xh538 +JC6ra2TTXCC+vH+Y0fKihEyDvKNujzxZM+1fugF8K79RfAW8GHiN5BH0lMnAgn85iyURs02DZx+8 +NeWp7VqU4xNu84krPrxh0wRbR905op/SYKF0IEjfdbFktif2kthZEnqLRymWTEo6S3aP9DLHkn7c +enskzJKssK3gWDKiglmSde1dUCwpUX+W5F68PUrXNbOk9saSvWdJJ1wAhIdyyi55FhfXt6xcjkml +iknCQcdkSDvCr5g8UOCYFN0msi8mHc49JrepiSQ1hH7kmJy2SpAsJpkjHJPR70hrIiBlo4RpddqO +irN0SS/HSDoOsv77ftIbXizKuQiCZcuUiHo57Q/jU4KD88eL0QV601DHKSa0gWRZxSGdY+7KJcLB +ZFiAoghXTowMShg7vPLi15SyVaTy7UKF4MGSZgpL9JlvLtCBNNQHKyw33QgwyQ6CFy1oitCgo2hX +5TIP/hF9oDs4VUfSSsp0KkiBNFZHOnVmliaxB1bVbvpM/1O/wR/oswWBX1aDH5MQ408kjVw4VhHr +JIguezGt6KDZP5YVP570BrFUKmP7zz9i6yU6LO0qKnxNA614JHV8PXkAqFVB28c5Ev7rqwS+qzZG +VmcQPv9k2qFdhMfiKBI8nX4oW9urPCydw/D1yhVLL9Z7FsFSwhwGdHZiWUlmOCGE2A9lY6JIGH0p +JAcjgnN3X55Ug5lltlx5AAgdtHgHihNFOoHQzee3OxABVjSzK0VtLFLCeQ5kL1gdgz8BLVRxGlcH +x0RFCOQEuSDgHnKErSXWyjOmkU9unjougPqHIjMlgRiB/6IsbwyaiO8pixAaBDHyFgbhSHgNqeam +wblDByacCYG0pkwRoQgOAcBwDi5t8CpTOCfyX3zXubYOvRGqolpeMVUWcRe6bsFEa5bEiWA0lpvk +MEi5hCIRbQT3KQ4cXyEvOEu+j2PmBvJ4KwkDF87+92SHrqMNtuTd+WpVvn+g9PoTzktupc/WGIVH +o8xjTFOaYaYEkCE2OEgwiG4t9g+7yY30Mh1IcukeXrxMhDcfKuNQAr/SICi4QI/YV4cLJGX38CeK +hUSlvidIOjsW/VuLWC95/5Ng6XwIrKYsWmIZ0iXHc+Fw7TREbGcZCJcfly1FFgVmk7wSpXKIcinF +f+UeNGHsg1JYv1eKYvOqbfUeasphxTUReLF0hUcZgS4mKawUR9xuzpaEg8+pUSXPki2WD6y5TQzM +GZiqYFxa2esgYrmBYksYIlKUFNvhkoL+G0pEz56K1NbmHKoMwhBXk8thKu5z9jBINt1aw0tdt0Rf +ssneyEI6hTBzMuMpChYQdMfIR6SI1i2DymDecWSju+R+SDViChfzDqPnwKBlGY9i5zIYC3Rtcy8J +xdbhynUrctD5hByHW5lmOZagJPEOueFKgpQRduRwWHrfl5fI/huzLf2/p6KW87c3Cm2l+vYX5hWb +r+zLDermtv0HFkqWIu4bU0O0+RAcOOGB3MWNUrqd042wFPcXzEeEDH3ckm/3Fb5cn/mtSW8yOQyg +OAQkQtAucASSAUl8TpX8EIeExZ06626TBRYCnhO7SRd+0lvys6Zq0sDvjBpDLITzt/aehKub8uV/ +RLV/iGCDxxPmfzik+w33j/COOMvmPhrWdu3Vhv8bSfzO8L3wUgUjZV8T/hEzup1+wEUMaFV7Dwl7 +ltPVrZPOih4YTILDgvd2JtQ/sPJEA5o2QeoXyg0lgl+pjegeQIoJKGOcXtIY97uEiTOzs5Uo6wh1 +LdkvRBb7KDK5X5xUhCBlUWD2aEHzdeD6QFzi5D0gfrH5UQPYbO21sD9jsbYis9XvBFo32tCjgIwu +X9D5UcgWhts7lk8r0dDdHYS32kGm21/EcW93N6cpjS+9dBAB + + + + \ No newline at end of file diff --git a/internal/cmd/stats/header.svg b/internal/cmd/stats/header.svg new file mode 100644 index 0000000000000000000000000000000000000000..b97ba95adea196efa0e62202d8755e91f09c1f76 --- /dev/null +++ b/internal/cmd/stats/header.svg @@ -0,0 +1,673 @@ + + + + + + + + + + + + + + + + + +KLUv/QBYHBgD6iO7nzkgRUTOAwAMAAwArAHAAMAALMA6vrpQuiak2EE606A//CCBBFpwbds20ATe +dW2nlNQ/kJiAAQAbANNdCREKHgoJzcRltwzPmth9OZ/Gx05q4jiOp/vWJz3XaUK7Zcqncfd21fq2 +5TlVvap6Tr0cW57h24VrF0Q55bdNaI/bhi13fare261Z8CWhPdy6Pj+GphiJBsyoAC8MMrWgTkJj +yXB9dz5NoXGeGKB25Rm+U7BD+0xc5t6uqpJXE+hBf9uwBdV0jY8dgmol75BWC0MP4SQyV5TSZBkA +RCjNe99v7GpU8vrQVq/6adnjy1QirWs944g0nqk0XirQLhrvmgBa1w40rlU0ng0g0nh2ijgaz1yR +1rOMxt+YfsGdL2No78vFfJqidMcxd/WqBYnMockCzPLAl9lvPMDdueuTi6Ene+ehH0Ow89LjGtg9 +WHbPR7EEOel7Kfb+P1j2chQ9rpVcLEs++rD7sQR7lmXZKZYiKY6iKIYiKH7Ri13kIhuuoRqmIRqe +YRmS4RiKYRiC4Q99xoIrqIIpiIInWIIkOIIiGIIg+EEPdpCD7F+/+tOP/vOXn/zjF3/4wf+///3z +z/rVqz71qD996UmfjV70oQf99953zz3b1672tKP97GUn+9jFHnaw/+5777yzfOUqTznKT15yko9c +5CEH+eeed9551nkPx4xM+/fqX8kfgtnJhn0USxHt4x/DcRzLMf1kSI5kyctfhuVYlmVariU//Qme +4lme6KmeGXt29KMhOqIlivLUp2AqpiPK1a7+c/0r6Nfuu/dlpvo17JmahWM4rmvGS152sAzHfvrs +g6FInv1zUMxUvobo6skM9WkHv0j+8p9eBdeMDMs0TNmQi30USbEUT1bMyLHsnwRFkizJk0TJ7KNp +qfKzn92LI1meaPo5yMOx5CdHecZ7777//jvYxT6eaVc727nnvvsvnh71ql/9/x/Mxk+qf/3s52AH +PwmeILqCLMjDMBTPEOUiKIpiNookuvKxj3784zmiYzqq4zqynvQneZIZSqakSq4kK5ZjSZ5qufrz +Z+JInuWJ8u7RP5LliaYqyo6jKHqVvKpoZqNd4yXocS0M/fj7B8mRi2UHPT6WIPjHsvxkWXo+elwb +yfGXPRy7CP6Sjx7PbNTEZR3UxGmc9655bfS4Nobdl5+TJQ/B0YMe18oyBL33voN95Nzjmih+L/4x +9OIHRT96XONZQVajJk4DMzPjaC8zFEXRFE1RFV3RFWVRFuUpT3vqU5/+9KdgCqbhmI4pmZJpmZ7p +maIpmqZpmqrpmq4pm7IpV7na1cyrXv3qV0EVVMNRHVVSLdVSPdVTRVVUTVVVVdVVXVVWZVW+9rWv +fvXrX/8Kiqu4juu4kmtWruV6rueKruiaruqqruu6ruzKrpztbGc969nPgiwosiI7siRLsiVbsid7 +siibsimrsiq7sivLsmqKniUpZqMYgt93LmbsqqboWZJhRo5iCH7feciyK6uyKYuyJ1uyJDuyIhuy +IPtZz3aWs+y6ruqaruh6ruVKruMqruEKrn/N/NpXvrLqqqpqqqLqqZYqqY6qqIYqqH7Vq13lKpuu +qZqmKZqeaZmS6ZiKaZiC6U992lOesuiKqmiKomh2oiVKerSjHGXP9VTP9ETP8yxP8hxP8QxP8Pyn +P/vJT7ZcS7VMS7Q8y7Iky7EUy7AEy19mvuwlL1lyJVUyJVHyJEuSJEdSJEMSJD/pyU5ykh3XUR3T +ER3PsRzJcRzFMRzB8Y9+7CMfWXEVVTEVUTETyHhmZr480RI90bMk0RElMzEMw2wUR1REMxEdRREV +wxD8aCaQmRnv3HOQh1zkJC85ylO+cpb7Dvawk73saE/72jPuuwd96Edf+tOnXvWs5///8It//OVH +f/rXz0EPgmAIiuAInmAKqiALsqAPfwiGYjiGZXiGaaiGa5h10YtfDEVRLMVTTEVVZEU++hEcwTEc +xZEcyxEd1XEdOdnJT4JkSIrkSJJkSaJkSq4kS/Kyl778JViGZSaW5FmiZVqq5VquJVvys5/+/Cd4 +gmd4iud4kmd5lud5oid6pqd6qud6sid7crSjHv3oR0EUREM0E8gKMhXqNa5lilBjaar35cryJ6E5 +bvmFVW7dyqjWs0Yj7GiKl3ZbHU0lx2b6NafvDcc6Wv2qZHjVul2N6H5lN9bcMuxaald241cAbLFV +8hujWksAfKde2q7fFNyi3ZoFx7Gb3vV512frdjVypp5vWLVqssZGwZaEptDkGV7JFK2Vul0BXpZq +1eJ59sTz7NFadqxGtfq9bzf13vYAnTdOwXftal42HM+o1wAdeo5fAfL6qufVqyY0hfbWbswqaNj1 +EpqCJcMpt/ZoCU1xp94DVEZTvQCA6/lOvaBENq9kCh2b61aGWbFYYyIxGUuGbxmuNfKMMdGx++2w +5niuNRyX2bf8vl5zqpJXjss6F4/zS2ry8nvfbsx6sJNhF8ey9CX3Ze+c9EjNjyq4bk1j2P0ny7Lk +3Ct72NBAMGw4ZJgF15mX5Wq4UYAXBoWq16SSVwHe25Xr945P09h5qIlk10L/uzaCodd62TsIhuIP +Ra+XndS+CH5S5OIXRY+XndS4Bpa/j5+P5MjDHnpd654USfKTIh9Fr+xf858MQ/7BEOzd62NvaI4X +Bs0e+aUzJt4pJ5eBGugdN8skwLSCTbf83vKcmmSY9aKmsdv1O4BrecUw5B2LZ/vl3C+rdj3djdm7 +Pk1h/KrmS0k5uaxrjjd2U52XVbsWE+g1h33D8qd+4xiHcxDs3PX5yjNGcwjwwgLe/335Sc55WIJe +yb/2+ThqozhysZdlCfLQE0Xf0EIvhj38IVmCf/TEH8Og170WemEH39uN6dfrIUURJEexc87L0vOg +Fvr++cjHshRtV+2+6LlWlOQZhlkvpyChcbTlcFzGNYfNkuH7fjsvy31RTCPl5DLNYfqUk8vE3zVH +/aJeMYwqhmOoyXfK2bXbfdUz+8YwawopJ5cJMMfxO9LcW35Z7kWT3QFSzWHXczyntv2m5lpzy+8r +lppVjstG8IekHzvnnHPvg1o4kiLIybIcQa/09suBJxyXgaDGP6lxjWuOATPKkmGVHJtRjss++cWu +OV4AwLbtCpDdlOOyi0YAARgA/w5QE/3n/dc1x3u7qjzDCpYA2/Pc5Kpd1VS/KvedmF6Wa39MLTtW +pwnN8cY37OG4TJHQFPWrqt2JJsO3HaskNEc9s93jX3PIM2zXL8hEUk6Oy7gHek3BSdTz7da3HYtj +iqY56vl23zrluEzj3m+8qFl1NjqajQsyyq1rwybAZRaOCW0TgoLKQNwUXOMqI+qkQZ3i2gFYxHga +BpxewMITpCU9cqtUm/4mZAUMSgxLSwSSugakluqCgweMBE2Do1RzWSKxkXwR15IKixkj1SH7oY4O +Kj6K+ZZEIDRmx2Wie0Cq5X5QccOoTHiPzJIRzJYAqTYhjkA5QLRKfEWwuezNEG4WI3rZkUGAeJHb +SO2zoAbBTAS7QrJ8RgWLkqY0Ui3riV8mCRPh47Z+jSaY3KCYXgOWAf9kmXnRBxj/2f91IMI0YrvE +SRJjdVDiI4nLWFU4OlBFM+SA4sqUFAUhAeeyUmljYFrNS4PkEVMTY2JRByIhningsvYmuOkqhjjm +JrQmkIPQV7WrU0GECg2CkVqzi6UsEERAwn643/AHFRf4XPcxcAvBFqAHQLwtQJ02rQ5LRoC4LDNz +PAkFQQHmnQwgYqYEYxIHCApThzcKtgKowxkF/HfeAQ9BVgYKIpd9xKPTcsUyHEx9IHHAKJs1lJFw +2XaglKXFhatkaLvwlMCAVbnGgki67JeTHBTNmGiMXIICC8jk4Vjo8aDiXOYbLkec384fVLwb2FGU +UZMO+PHeGC1cgMBxVziDAKxrLstIWC/C4yftp/VaDhBDy2UfgkLLT+lFdnGT1GqjYkCufLEEm8s2 +qIflpNGjBnUMBVEygLKcy2bwoOKXmAgrhC5LsC8LznLR6FsYjAgFsYvcnMasnMvu9KDia6Mk2RqU +hWpAuY3BsgS7oLHdbiJcLlP4g4rfHYnCBTb3K8CQkO7lvB4CXYnLNiwv0JEU4Io6F719UYSiddha +s4uRlu8JgKlHl1GkWxhjDyo+GhMvYuNOsZrLGOs9OzqNAjyogAIbItWcJpx+qf52iFH6CPVcluiA +H59ZBX+bb4vlE/kCBw79iCb3dcwGhw+ZSk8eLhN9MqH/SDgMFpCVC3jpisdtaHbrl/F0LlUlmAiv +Ikqbnb6mCf/AMuukHEwfCUFD9qcLZl2+EG2zPyIKHH4MjPlzZRFwP8NhbUdN29wQ7QTH2DPKZeAI +8nFakW5hzCUunA74cQ4DtKBApd6xQqeN5LI4xr7ar9jmEUaxsS6z2zoHLcYmWUmNbYERUGwZL9Vt +F9Yzttv2dW1bbtLyvY6b6T7F4E36n1f5PF99gFMQnFTnMgjYvpyuDZiPOziw9FID1f0PcUB/Z6MM +qCHLpVtjaP2qhYVoduQpNLtI5TTZGL4NegwShfNIGPGb1p+cnlqcnBu5ULJcNhASJ2eKuD/JvTio +f/f5XgIr94xYArUjl+oVcxInJyERqesJBILwrhlidltp+R6XjVQmxsawJ4mtw3rGVqAyMTbOS3Xb +5KDF2GAeGtHYClK9sFq5JIt1hEkoGUnrPKBMzPdhlFJd8yHwudqxyXdiiIuLRhZIx2UX29vgRpFu +4b9bka5gg6z8iyXauaog9X1uQeo7eeSAxPJVSumJzaTOdvbQdI2pCiqXkaXoxP1BQeqrO7DsClpd +RKWsn5WEwMUSqDMN15+hElj5glOQ+mwPLLtZzZQcAqW54D06dOIMuj50AlUiomYOmBNK8tLQ6kmT +kMAktD+Py+hOaHaJEeTj/sIbmyq6MLaOI9Zt3uqk2Fpwi7GVBBjFJuAyu42D+BobZPIVW0vkQDrv +2AiA/tJ9nK8+DsnCdJ3f35KNxKYjJq8RkGA+QgKG6lwmaXAUXj2M49mB8+vsbAOQTjyFZqfiPzTO +7KkhQPASCUTyk+g/MExKU9I5qLh5ykRAlxqaHesirR1x6T7+oUCRbjyFZsdlDO1VzalXDUkKJe2c +n+bQeqecX3YPeD29U87W7dIyPBFgbnVYr21iybCA+fLGcCtEu3LnBMuwputXAFyr2gAoTfXK8OpV +E+Eoz7c8p15zymW9smfTMMqz6fgV8MaeTX9UzJ5XNR2rvFteiWJ91y6qXsnwy3LpOsVfGY5Xr8p7 +/IZv1HwwwG2/twx7r5T3fEMUyy6mK0VDW46deKVk2U3JsRWPUa78enatqukB9PzecEsA235rmo7Z +PqaiZ48eYNPznKJnF75l16MH0HCcYnn0gPp79ADPpu1Xvt14db92/arclu3ZtEu/5lSPPZtu3bNr +gFfKe3XajetXwBzDt5xKxXTKFvDGbsr2VHWqC2h5LE9VhwCo5xYNxytPVaf4Lb9wPXuq2pVyY9RM +23f9CpDnlFfHKdX88uo4xT3UfGC78GpGySzYflXvG6OpWX3tmOp9Y5TnerG81Wr2XC8WXdPu64ZR +Hmo+eON4TslwHb+q+aWi6fi2aTq+ba+maZVX03QXfbl6Y+9luS+Y7ALMLa+m6ZRKdmXY8q7qVxO/ +A2AN99q8kimouaLdMvx4Wa5LdmMUDbtsO47d+bXrV8Bbx/GcYslu7aHmg+9LdDRrR7Nq3c6g3+jp +ZDjagDem4Ti1XTg+NfiWT41yamKnnJrvlBO2ZxnWZlkrPWoL6twz5tM+7eEToQEzSs91ampBnYR7 +D3g92iugjmMv2Nbql3ZprUTZmgquWdoAKfmtXdqW3frWSsrpSQ/6sGZpXavneE65Wkk5RzIEu9ZX +r6V70d+dOI6xlNOVGPDenJ49jduGVZYMC/DCIBJ87dUqs3otPwxFWP7ELPl8Uy4bgu7duVevJeye +k+EPQ9HTieM3/W/d2i4cuxp8y66YgtU3Rrme9+ViYjc1W2yVUr+3TEnN6u1O4reT0B6xOY3jN+2u +ub5t7ZXh+I7NE3kGAFzPE4/eq9dClmSoaTWZommoV6zl0bRbflWwyq3bd8C+X9s+3xjl3vVMD1i/ +OwWrCe0Bfi9+kHwFwLcbv2FD4zPMemttwBvTrzml0XCcelOyG7soTUCj5r3/cRTJe/Vaaph97wFr +HL+p2D+plSMHwxIFSZYMt2xYdkH36rV0zn8fR3r23C0WzOlgOWq3DNd0/QqYtVdATa803imnPKfe +zoBXJVNu9+4McMMxjIJtGPTKbqy5Y/Qdy/BPzxrtZbkxluxatPu131qj1a+mll4xWPVeChJay34v +Wkk5+w2nXDoTcup5jlmTCq7bURz9f2OU9t5uPNswS7tjsys1yy5N651yAgCu39t2Odp8x06Nctbx +nAI9mFaNcOzeM22rVC4tftt9CbCy+716La/5vmfUAAO0bhUswAA+wN8tFvy23lQdmzspOO4CrOxX +nuW4w6i3U7sCBDjs2hUgwGH7x2u+7VjKZMuHhBy/Bri3q74x/XJthwD7vWMouL5vV35rVQy3Ug/7 +fjtvjHLh2hXQVe79vugB7jIWuwLkVzXLjVc1y/FrgKrwqmaM5SL6Hnfqjd8Ob6XcVXtoq1dl4kyc +iYtjmHWlZotNSWjxPKOec2oSWlcpnw84iiuuKl1V6KYaYKosGyiyBBsoN3wbKBBEhR4VJl4u5lXN +KVj+fJqia6ranVF3fSZctXsZwI1pG1a7PMM3bM8azuyJX06FksJzCEKMnnhB8LmeMr0XLODrX6BS +Pwf+ifPVGh5KGKfrkMoEt6OzR0NMjyqxKF0jL6tNCccKRoKRYCQYCUYKiQoogtvTlNBXXHHgKpct +YIjVIWaaQXJH7ngOMfagutODKmUMcFmFvPiVfVVCc5IeFKdGpsBFA+dEBFo7BcPPfj5oDhp1TlXI +uehqiqgPI8I+HFQax6ZgE5BfMYOXiiBVTMgJWXPdrgU0bepsuByx9SDuYBA+TPZD4OPdYEsWpB2D +H7ZkeoGAXJbRPEq1d2hJkf0Q75D7LfkHyJhUL2aW9GREqwGsIJbvhOSyDop5RyDLQ1EQBRF0YoSb +yb3AGY7LOJMSZ5FGXOZSSwrO6DG8ZBsGJQX4EQukrSAeKApDB1SALo4BlWiFmJjrBj40TVMemrL8 +/iQ9dIbWhzc9rCkWKLyHkcAknOileIsFfP2EU2gkdExfPsIXa50opyj8JgSfv8GS1o4WiHjqoeyS +R5hEqPdMItRLVzaqtVHtt1FeuuI2ykZtMrXJSzhWnOMSfS6rTaEUwYAhGDDEissYMMSKAUOsOD1q +QSCQqKaQ1p7SI3rU/c5lv53LfjmXzaCPSPERfVLQX7wH78HL34M38R68/KV5abKXpjU7opRLwdOE +p53JfTuJz30i/9Y4IhVYafixSo9UXpXSUHl1h+EP9MgnOAyfyyqfqzR8ykFoEqF+wur+xDZFqJ9M +ukgDf1zWRZotAjNvhsRGtYbSt6pEJXKbXetbVUu6WhXLuqw9o11BRHDZriW4nWaMPGO0GjTWxmg1 +SKw1Y/TJkEDLinPvcS66WvDoHueiExAHjVqwqGGSc9EPmg2X/Q+XVbxUt0m4rHsRKDwJUhrPSkFB +63ypqEviSg0438ZzSPUFuxrPWaDS6pzSqfAEpNHibXDzeWlqo9rRaCTqfvfSFZeJvISD4TsnuBeC +eyG4IYzUwkghUUfoK06P6BE9aukRPQKJRqS1p2kKQ6w4qFN8ZtAMas2P5lz0lItZbNZlv/zAZYnT +hKeIv20ckQF6gNs2jgFaAflE/u+fyM8G6G3jiMQkIqw0/FilRyqv0vBjlR6pIAb6r7wSlYEeoUvD +tys9QuGy7i55yl3yayLSbWB2yVMoB423NbuJbd4ol2V+wpqcDPzJdwN/2loqEeoTpMtzGeGjPut6 +FN3XlTFaDQ5rY7RuINLaGaPVntFuNXhVP7slLuMysTvNkPYhdn+lDQqf2DTdp0zq8ycqktZ5McAu +H6iT6j6XcWBpPEMHHH+SagqXfV46GhmI0FQkJhwr/mtTa9am1tT2hKMAgvBpbapNMFLbIPQGoa8+ +jNSaoVDI5hDcnn4YqTVhpA4EAoFEKWLFYYgVhyFWG3p0UITnoiM+f1B5RB6RQqE4TXiDoThNuOI0 +4Yo0dV/+0rT/pWlfmtbsiEzuy7kMYXJfPkAPhNwn8v+2cQzQio1jgEOMn8jfOCJ/gP5+rNIrLxjp +xyo9UnklQIfhV14VQ8Jh+JXXrVIoBwrKgWJNb5ZdqtVEpJtEKOg9ePmEZZv3IXL3Rcw0mbAY33cO +giD8B4IgvAeCIHzpalWJF4LwizZrVS3KcVkTwe0eRTNGszdz2XzQmOp8YWwIjVqgctl8YZidWrBI +oCDwNx80cqeOIAcVj2OsKVoShFFB6jD9W8iQNpepYhgSNIBJbAIsgdnJdjxb135E6AUsbL33K4gB +xRFuSJcSZ7QEZI8Lfkv+i6ooMWIsyH6AKE/qW5LCICDVGyRDsEMNaXOZtwnUNSkmwgqTT1DYtLxs +tAR3BKZMlX4oiB7PzPJPs4TSCDTwL46hIH4qljAagQUx/SkURI+wsASSAvQ0SpKtIH7MJ350De8U +qzUKsIAonCZcxZJErKbtImrssDVIT6eLqJ3KW1BDlQ9Nu0v1QEwQg3rlMgt6f5IOiCVQx1XBapyE +aih0fkIptOIeOo7nfCCmeoWVtE6VMl++ziWJJZa4Pr+iQKb4gotd8g/URrX/89IVt1GjW4R6DiZC +fUpwidCRjRp5Cq42taJOhiB8mHAkHCvOZT7hWPGYAQThRQsuQ1wIbueyUCgUMrwEt6eEviL0VSf0 +FYeRQlzGUHSVtHaVtPa0oZLWrFNJK2LF6VFLj+hRa4LShUlaO+JxLnoaQkG+z/1yyefzIXTORU+9 +y3551l3257JnUPtZKE4T7hF1cqc15U67kTutWZuymOQ04Sk3uS83uW/D5L78pWk7orY0E4rTyH0i +f4Ae4LLPfSLbxhEZSGwckT9At6U5YM9ox2WFjSMb4Lcn8mGOyqstza42tWZXgZWGfxhof6zisl9e +eR0qiYEeqRw8h+EbBnqkEqv0G3Ka8LBllzyXjQh9xSkH/tKNWrNll/ytXjaOyGFNRCgHbWlSMsKa +iLSNkdlRbjUR6Ta2gwZNItQrPsrsuMyjStjmzWUT27zN7qWJ3DM4gMZKfsKazJcJy7zNjsvWrTW7 +SWMkQj1n0Uhr0mV2G1+T2eoiVL5GxEwT3nIZQ9KCIDyhvEAQfkNBED5GurjMt6q2NFmExuza+gNB +eJp0mR2XdQmUUSkJUA/zKJgD0s2tr18ILhdWuIzwUYyVwbowC8GdParAS1e8Y2wyBAnHaj14mpd7 +Kmn9NGOkitwJPyhOH5P78kOBRFo7ynG/ZeBh+KuBYmBNRLq1oH8YnXFnSCtn0Ui7w0Br/tYurX01 +aEuTTkmrzBiZnWeAXGaj2m+CMLh9FsEQK56BWlPutI+N4V8YFw8o8kUul1kGeoTDZTHLuegox2U4 +CvBn0+MiZppw7nCacM1n8lFmp2ZCKAj8cVxY4SjHnXAugx00ZrfZpItGyq2m2W0u3fc74MfZA3TR +1YK2NLlsUVDf1sepb+tDvUeVoM3ZcS56WoBQ8ddBY1J4hHGBIDwn01JKl9ml9owmGAcV50g2B4JA +6fAEcEJyGU//B1nE6s382JwIhmU/6hoVEbL4Q91nUItmezJZqk/YkhwU6HExsB8PS0sEakBq6VO0 +I/tR11w2JjaS35I1S9of+/FHZcK3pD8dixNnaCAIb/gFk/O2CrZogyEVm0BrTLyIgjiq21Y0tCcU +6HNxv4NZ+VfByPT4sqEg+oyIstxzWYJtELlUehGSLNoCzMq74xSruUyBgsBf4SKwsnxvMwLrOiaC +fBwhk5cvbuOCSELRCu3Pe1VCs0P8gtn9Syb16BoaCMITapcpVq+Ec9ELh4OKN/qH0Qm16XAxhDgq +Y0s5KsM7aDE2z0GLsXHZqKw0ttRldpshZOhQ/Nc949kpfal+4Ei0vXVbzKeJbVZXPfDhY9jX87EB +stU/JgukE1GOK19oAQVtHrbKQcUXAqrogPRBpM2QIGwFQYCgyz4yIRsXBsI2GDWkrRmBCjaXFfx1 +UXYTclG6QHKP1fSo4hl33RI5kA7oc3E8hGkUKiy79jaBiuMueZTJwLnJL522JaY/BQWKISmbW0Q4 +lBgeH69HUmKgNTuPcVBxGEhRIbgKE/uqFCqllMtUHEPJRiBh2WU0bkytXNDQGqHtT1IRuD+JABiW +nbq4PwkIhoZW0M8HB2I8uGYDXwhd5x/1AZjkJrTnZwkIGm5aEgM8VTUqnMsssMMhcbVWUtOaktPF +pYKnC3d/PtVsWpPLDoQNxQDE2BwYAcUmEp2exYYpK41twRK5TPH5rDh3xpcTcQB5/ct0QYlhPHE+ +jINs9bg1uySwCMnzDUgonwtExXGXuIzLuIzLGIoRhMtS1kMG3sbGgrF40GKIXqrbKo6Yx2XcITz5 +z2iTT0NKovgZ/S69RnBSnRYZJPzhbTEfJ7bSSAZsy4pzGZeNllOsli9cdmmZHvbxWggeEvHQkAyM +iDvkPsLc+JPLOodZ9ZZX7HyEgkGUrBiB1Ve+ESahKSmqYw4GUSJqtpl1wFxWQim4bFMJibBAx2Vc +xmUWxNfYCm4j4TEAMbYYwlN7QkkBDP5IYuFixU07onqN90ASmk20fXMcOh+XbWqGP7cucP6CXNAZ +3VSldAli0FegN2KK7peDejmXMR4RLnOsJvvgfhHdkdYKLuMyQghS15qVODkzZKWUeg3FFzmuUr3C +QgawSPCTWFKDSIV/WQw430iTQD/hRB1+O6nPX4kcVK/YUL0fdJrblzGHWy4jLTgR1lFiPFZHxWHx +Nuj55k30Cu3Pg72lsZVy2f0i+u2KUFGENxQFG5wmEB7MnQkgm812pLUfuPHJLmKXiT2/8Fx0GCg0 +UQqDg4/nJC04EZHLNBnRhbF5BYYEhvWMDfVSnXhnVp2GYw6JVqesCI0EsVIwHyf9Wr31LRidwHav +5zIvwsEkjD8AA4VmR9OxwWLjMjvG/cSXuEscSITubFqzq1zGWPx1EVFJUI88MqhSvT5oz0rCZaWb +JVARjplx1qVKq4tIA4rPwKCGD5RKoeS1mrzcBc6XbwH7YBI2JbJ0dtO5OONilzyXmawPoxuw2s5l +XMZlXKagWFC9AXENGN1jPCJcJjkoIvRE4bks4jK7jTfIxsZAF4qvMpGrkyJEwxgbJxRKqc4xN+w/ +qCaXhCKCaSRclhpEVC+BoLoOkxQwX4FB0Do3Ken5mOpwewFRuuJ1AbbYbs9lbOhDf0IzRuraPPis +uDexqZhsyA3E7bksgZIhq7Q+cTzV1CI9JAtTynPZ6Oq6Z0qd3BWlpt/BZQX6sFI3MdfGZQeGSecy +kEv0ccRpY2yMulZsE9GFsan+2djMfzYMLrPbMjSMsRk+NGLLNMjG9iBsug3jpbqthEgQdFtFZWJs +LBh3myRB0G0YxNfYPEdlgApgGglq6Yweaj3mtLgwjUR9pUx+AIO6JCnixPkqomnFUdeg7bcrQj2X +EWjab1rxV6V7LkttJIPUc5rU9DtIsNUhMRi4LMFlXUFD9V2BY4BzWUlSDaCPOTmFocqWJJoMKqt8 +XZg+poFmLTYbMXoqSUa40DSlVXMeRTIp1RcIIFw2qiezBCZCgCT1gYVTLpNM/Ip21Eoprbdwcg5E +7k9i6aCh1dK6MVXgbggIYAqYE4oKiJNTIoqTs7xoSo6RFanrzYR0fSJCVThssTh8LpMzBpwvcYob +SYyg0up3RxBLJo0C7n8Hr+cdnUUG5nIZrG71+TmbtXhtVM9lEFYj1V+JFMH1cS4DO6S1v84J6CFu +HmrNLsEQPSwbjr1ZqQZQ9ZwWAVbmLEwYJraVEU/r5rlNLJdRIAMgyQuyOScNCt4VThNuqj4rzo0g +Cn8wHxGOq/KQ9NJtYkMCnqf6EELg/L4OgTxniwDuVrvAZQ/Qc/leldDszO1l8vUoND8T+/G0NHNb +S+RAuhZBRbB6CNddLZwJbGxtsc7JZa66fB+wr3pOF6RtVZJqGHiY2yFmdoQSY/u6vjngx2EV7bHK +2vCLop7fCZN0WDNZN4mEC4Xf1SHhWBwFUWFiP954pbiP+M5FJw9chirSLaxlrvXBPDYiICEkYp2z +IhZQ1MTBw3OOOAiQ5PTBZIlrPbmr5PZE/mN0iT4+S0hrbwgUzC4Bt2aLtj8JLwgnJ0kEiltTthA8 +DBwG9cpIkWuZhi4ndDRGPKxCMjNOLquEXURlMZvUKeE048SM0NDquYemEYHDRyxQ3dfUr8KvTB5K +1kYB9yPpfPkSni+fBdADPksgBeb8IJZTaMZteNKt84MHpvNz2VeBTm9wOf93Gu374yXMlVoPHHz4 +iU1rdmEpNEFRREDisbmVGk9eBOLajVD/WZDU4HYV0tq981x0r3Ca8K/gFrD4w0bUlibiAFwF2A9C +ZjkOAu6nNggm7CfyUSFBaIIsV+RDgHqUd12Q1OD2xElae4M8F723BLd7H1cjZKEoArGSH6kStDn5 +BOiiA0H4BojgdoZKWjujcJpwj4P6+Ms7qLjLsKBO3xOaXb1ZHhJYlFIYLApQX1q8DYbk03PSA2Mr +PT0On5PLUgfkI/LP6OMfhyyQMZphxVa6Uhi8Dy8zevR00TvK6ONcpvAMCk/2RL5I83FbZZd8R9kl +zzmaj9siCMJ7Fs3HYw2K5uPyeC46IzwXvUs1XFbe7oQvCqcJ95/24/erNPwuPBedZ9rvs7QfN2iw +rUEnaRUFhaPlFolBrOSxpiWC8AyJgMNg7Vy2cDgSCRUvJUDz4/1z4RSlQZFPCoU+z/NSxiQ0Owah +r/hiEprdIoGYhF/hNOE2KTQbhdOEM0wi1BvUt/WFIwjCDxQoTCzpZXA7hTJJsDGOoXYGodDAaVvm +10lKSsv3Si/VbaSN3NjMGFZsBoevsWn+2dgeLrPbKi6z2whuI7GJQhfGthqAGJvooMXYYmxpkdBw +/iQUZNjPZfbjr/jACj7/SsDw5wy2L6eQGp2Plx/PT1aB85U4TRpv5uNrRmDl7TdTVIWjRIOCvXHL +hHNZayRq+astTW+bdDQcGj9IA4GB2Zk8THVFdDRrsR1GmdtGWGJ6IkcpNkmaWMu7zF3hXHabncZW ++Wdjsxy0GJvN8TyUZCCk59WM6+Vctg0cVF/5MOfbCA6pLhHAnD92H71rPJiT5+xKobR87zGT7Po7 +WFdOaHyQzkPPBoStCpF2rZGAqRVKF+yC2xGrKYfZrsXSadebxOXED5zFibdkBIhzYoyWT0ollkvW +kuXf0RAQ6EYKcAPG3VbGsOJT4MWSREhwOSmh0MnfnnnyAxRI99WDiMm/YqzL1xHBNBKCy4Z9Mx83 +jaCVbyZ2hZkoDANhI8xWrG5E4LpuYTItx8yFlrMhD8vNCGc5l0nA1KOLM6jfLm1psWkckbpWZU7F +LS8DzExcurIDpSwEikfLwLlvfmfCZvh1jUh0uPqWEHT06tFpXpmDcHSg6FKTKaFoTaDY5Ws/Mbxp +dVYDdO9wGQg2IWHKsvMOtDAnl4RlUblGE8zSERt060VYOrKLUwuhjsEhGowIH4c9N8VN2QgEhoLI +oiTfCcKNzR+lZXdieIaX0D84uMa3qVmL7bT9JaYoSASYE6hE1cWxpCkhQzMjICIAoA0jEwAgGBwU +j0km0/k61wMUgAJANiZASEQyLCIsJI6GAtFAJBIIxnEYBVIYhKEgp6CS7IgCAP9zJKJ1AkVK/EwN +qzIGS1yv9nJogPvMNQxSpVtHDtV1t/Tu6JUdQ7ksZK8TZhozXj2CQxt0x66ASKGP1DLYDeYbRzZ2 +T90P+3ViCh3mHXahgcHCZbzGzqA3Fqi/2Y2EQtW3yg1TZJZ8wntwE2yWyXdL4pJyR0rCTkaYITgs +S9+jHGn1mO1z/8NQSzr2RlkrHbn/Kl6cfU0ul2BzmbOBrZLtifs0G9KDh3AAwgMixaVQSjIpw1JH +Df1PhLQVtxR2X6qMz4ylSYc1uSqgVAAiEiS+4AhjQFLPqQJEHHgUd/Sse0EWYAfPwJs0xidd1fkp +ME066yGMGvssJJneTj05P27Klf4HFP2juN89Hsr/UKf3y94/+BhxGc3dgDXpXG0WNhIVO8N44XEM +ps2vSYGYRXD6yYhoraokDkltlmuqm3Ky6DnCJIYP3idO1T+iOtEaps2d+iVAKFHmUttOe4AXkw4Z +gyZpTAm7hOiZZW8l1Dmka4nqiGxtikyFXJxUIcgpiotHC2TwwO+9uMT+PyDD8s2PDmAat9dRfwaV +tCKr2e9Osm5gBkwAZPb3BS3rCpnCcRujlc+4quaxHsHb2yDzO9NL3N3CEOIgKLwf5dsjm9mMN22O +NioX7o4hnpb6iohygNvDGySYW4tB2NIujNxYd1uiJRCAKyKrI8Ft7G5kPqQbGN6hPnDIaZZEeo5n +4jyguxcA71sMOaM9090MgDfE4ecegTHCLsJwSfPigjsS8twte9gluDmun3RvypwI3bG7jaASHRDQ +jhgylOeTtK3RrbWW3MbNDfEvlGmWT21z+QfTzXZkpAoiqLEWq2gPPV7kV0ABbSo/OWv7BVignLZX +FLa9g/+CKeyHMW2nzO17pxtRtS2F/XbiAqNk0FG+dMpGdqn5XfyouiXi3wqs7QQowE2ul/0iq+XG +lIWo7e6hQdZKcCno0nB7e8f9bCmG08Rz+SIUnp9Q21iM2x4wNuFAnYmCyTO/BMc+yw/uGkOX1cPb +RrIn12DJnqhoFjXSomPXrh/8yH0OkaDLqT3KHZmDDyKyIxPxwbP/Y1u4CRkMYltk+uqRWJUlOsH7 +gO7OZLXe3tXPDZ2NJDg6UnSgUh/CVEOwEQ9xP2ApPnGACGhdYnAbsGqJLa8iRL2CeJ5NZIwmpaU6 +QSkOs/0obXuMqdgC+n2K0YqZPcU80T22GU8QCI2ZZ6IocZ5MPECPwSj0ste5+qPO5d9i9cD+cTOi +o0O1CGGTDIkdawo2Arqx+XAwIfCK0/zpLroVFf0NjJ3nI4E2OZbsvmS1QQ+U4jky4HPNPZFkOppx +xwDoyPQXSBw81gFlfSVLrq2w6MRcqkX8yvNFays6wxgSH7bG8YlQfTBxU0xkm+THK6+vnWuJPkXx +JQprd7ukSY0Qgj54CLFaUQR6vAtIbNitC0Gus9aUVq3ktmZ3JBbyMQi1vnUWtX4l3TgaySSL1AJZ +PZqSucQOn3WRrPTUz/xWwaXKkMLv0LaKC88RVx2MOJ+M9r6ZsLFzLo6z2yvnQMMhMIVzrDgb0ryA +P6H/hb//Rdo2l5CM9Tio/UmnbgkyI1YnCYbBxCO23UjaN0nxU5cE1ko9yYWHjfpIKjtJcn4uwNzU +ZKLH+EWZ9/3tC9Ue5Ey8RpzSHVKGNFY9rf8s4ZDdMjkz5ahLNXWpw+zg+TSJdfQ+6nqQUXoZ5Swn +aiwWRi1iiZ6qg4r9IvZfsfwy6K8O56ix3IkrcqkRft3nob8Tpuy/gSJaMqiQzxypUW267E0Kr5RI +H9yBu1gq0L6DhmYrUiwl7NKRGo5+Hf9GtWlyFZv+R/YmASiAzV1Y8yhA8xpRGyocc42kAGmT5U18 +kO+aazece7tuSeHuq4qYXykmy8viHLdF+NU5QJhMeHaTTJcDHFnq0GunrEFK6K2tlCJaehwAmhvr +zMBhKTeUEzjuvtD+0K6bzPyBJGFI3x8r2A8Swj1TsrxQv//glMq5DrkGdU8hVQ12varWpkTbehj/ +VYtWm6wcj01sG7t+tAHJmaTv/jNvMcCMLMGJKj/B/pNGSdLa7K86wHKpWCA1jMOeQA/QFvll1tNI +F0PT1kcnjYUqfErZirCsRk397UDBDuiOTVQdduB0RMoHDgsuRT3IclzT5HVmFvgUIcimNk44Sw7n +2S/Qybh84u+396UW96xmGJIhiF6mB+8c19akNh0PyeZ1LoehLnIZS9elTUlhPa4N+3NkgrA3SwQ9 +Qc6EvzGHjIV3r2Qq/3pki2a5wm20/9iorttfMFCz3z75GvuBi8zOhOL/WPHqbB1jtzrP+LB//RX6 +FRXS8rThOfNIY5B2BMLAVA12HtJHZzKsU64c2swjt07bziECQv1SB/I74GuJMdhHGLaBqFhXI0Op +XMi82qIcgdejHYNf45EhYU7VtBY5cFX7IjEm5h9ZCDstStNuvANj4nhVCqXuLyz1C5UT9HzI6sfX +QHzb5/9lbQnfb9cOVLORh1q3qbbFbzKZZCe21J9z0qcD/D8dkm13JGL2pmoKjyIbPLAcDORm4fkY +WOzEzi++cKYCqc73PbmImIChgzFpWVTDcBeumkkA673vv+2qF+EPWMP+/VmdVYiwMpnKTodurSpW +PLpMlynVqp/MoPXRvqSMBqczOi3iOKNHd8JUw9RRIygz/G2uIvXLwW1ftdFOJApwRks9n2JrFc5L +S0HJ6D8cYwjMb7aYgOUM6SYG7ITD6Ok7BOGhy8FxGV7hiJ37bHxUIXQbVneFxh9hKJMcj6whCOXD +/iD/BssxZGXzJ4jEFNoZJ05RFM6JdHJCnQpjQ8uO6SZyOuvDb1/b19ai0SZZ/iCUuWP3fKiKbV8V +iueTJq4O+pJAFCp6Vi3Jfy7MJLUtIFET2nluMrn3MzadXUv9MfioMGVGL8XciLobZeKhE6EiWxJZ +EXotmb6Ew11N5BUjBe0zrhT271Oousvy/9Ctl0HN+qmXfs6B23gXGwwwWKw+jEdmSM56oFs/efOb +SvN3NiQjoAVYezNSZBAz/49hB+NEbcTKML1sJSjSLdyxoBH631PhOFQzgIR0YKrxiBOd58NF59UM +f8JFc88NN6aeQYBYyKncvNW+xR1hW3NC/e6jGXIQ+qmo7x5BmhDbi2IZUHJP1QTd+ihb0kj1Znaz +OFw8+pKlWD8h++jpqLKDiNtFqwNylPFRkkKA6WfVaLxwCXItjaiHqFuvUyDWCTIVzJkpVEfcMOvm +1ClFooBalOpjiWhO2JFpyvFeMB96ODsjYTIQF0pcJtjkWhE7s/33dHvKq0MgL8S7rHTki4hT4RU+ +4z22zCVLt+ljbnopWi/HRgIY5yuWFRFJ6IrzWtdmSrcblpuuDKd073N0ck6XR1e60RopzbKXUDs6 +3T57HN3Dx9HwBBe6/JsovARds/5wRZLWGQep+00X+2bpAwORiK8ImAsOOnts0gu7c4h1HcwOsF5Z +uWXzS+o5XQ+m5c1AiV1jxa6+n59znHzwkkEsrpqDLOuXAs0ZUFovvCp51LBsq+ptoXPkaNJOBHVR +exCXFr4m4br3RxbqVzhL7cVlAxrGtrrF/4UrOFRSF9/JiVCvRrY+gHw2dtwK6Zmyf7cyt0jPx2pp +ZlYN0MdHfzFpZVU26UekShFn+vwBD9JZcgLe30fcVBu/Ydw9KtekY/5Vh+0XsftL+tV77A+KaaFz +p3WKV0l90uONzs3EEJ7aVTp2cn7N8IL+TNMdkYBcmAcUvcxnOnyiSNPEufm3JMp5c+PER0s/NXP7 +5l62XDKEpfbDOdSQOzDF/V/H6BZ32spLins9OMeQMgg7cRc+HPRJcXdLFilqcULhZGhMsRR3x55p +3YkbT4DENOXb4d1zShRcDlElu3rEQwbIHFApxX3apYQm5iqcuTw6O+kEk1M7Ujbxde5Q7cWBYVbG +oU8KuqSkP0LKs40HFoWjhyZ9QIbTUWgB6HmWdT5Yv+emF5yzzk1yImYnIiXmkdC3yLnAOm+99BL4 +imV0lqlz6rmQLda5//dJK/W9lRDejh47Of+0MKY/Vr2RV5aJnm7yncD5YwMtvF0OF7lnQ5bHHD7/ +72TnzcFFy5wH5vIMWR9cI5V+IGXrgNmPwFYLAerAyMbU7f5spcEVzwlC39uo6WpOvlV4g9AZFrqp +JATdbJUg9/x/JULnRorvaX6OkDPRI/RAWOsG8PIUjNhpgASFbnofIdB4tjCvXrnAnOIZbm7kE2Eu +2YQWDcC8+XCAAp9QBzDP+jXtZL3lS7auvQwnsTUivHPYZOvy1TXYrI/3sesclsNpmxgImJfLqwwX +5uhLF+ZgLUm2jo5evA/eTWDuFtYMaCKrT5Gmh1ukIkbFiHvN9HMX34ibaA3b6xhLeLhtnsdupKBF +3PBUQTUZywrcj7jd8cZAHKB9Hn40OefD6++IO1B515SIe8taRNyearJM90e7ZoiHOyGSwHB6HUQj +boXHxxKX4jQzMcdOSyKExXzpMzGxo6tVPTOxlY2STRl8HZTaiP+vRGdiiFQwoQwOnSnj95hYdNxM +7CYzAMLYNqklNDOxd4VJVwMJYdAGuS9ZXTvj7IF9qQbHKGEow84RPhe5jiIDIx4zKIwgF7MCJRCH +950XTwMAal8TBEyFnvDGAujH/ZG7uJORmvo9jGwHPrcTerDe6azVxIGJEsYZtfTYrMdjpNRACSWs +aEsYxS0a4sxOEyLpuhb5JGIE4niLGGa1IIxxJnQorVR6IowJVrkNNW8bU1/QcYLVVOGujFrMBCOp +MdfMgscGE1DcanFRMeBzgpWrDkh83Z/BMKppT8+T2QkmWNu7AExVjjangYqBflCz0GTml5s5TDB9 +jLlHf4kFd4FcJeI3hqlfrU83opaBg5bRzt/Ga9qMGk/pmWBMw41bMr+HWO4CjSfVtBpNLoMdKtn4 +qRAYtZrmbpLjaD5hTs+fZ8MI3Lcf4S+BQ1hPTgZU68fDvMROZI1XDrPzSFiRgF7gGplEoqoDCmIZ +5nQUwzC8D5Wml3fM6cvbM3kCBkfy/UlDm76+VCZE7yhrepoBZXA7S4tb3HlkoSU74aN3BNtiZEHf +xfF/u2701h5iq7XvIhBUz890SpT2H96AzxrIj8jcT2+Tqxdvi95CdxhWvhQZvSdIs5JwxD41whc4 +llodIWHa+2cin5dDvkle0/EhF4RpO4mSrUVBtl2ur+QDg7J5EOdJyzu+r/gh/AvXZF9W+2kX+IrX +i2DNvDErV2oJ7G/Tr/tZCrwhBaNlc7N4qDHsZbmc0w1ArdElLSHGHUTs16JkYA31OqXairG4Fp9L +u4313bxLvnfR1zVVXLS+0mtahM9y8vOjUytAxxVjYGjAhbxFbxXMwbRA5z2xHB+4DE+aJsRmDMXh +PjCV0LeS/mYhn5FdRyMWWAgQf/Pt6u2dTTPFCBh0IyhpIousz+eaaBbGzTb6BBTXX/JM31tLjOVg +zLToC6r0a185VJi0+nK88iMaJaxhl+lrL2t3JBxaDFla+/7ParfsSDp5qu/ZMDlN4e1XQb9+vycI +YLvtIxRtvSestLgAa1IWb2epdTLsvPLeJRPSF3z2z0+qXFiLoKa/gqSaNqLVJyRAdVPaZN2+2KHW +dRdzSIikrZjdTartZcQilek9xB+E08vaEj9oga2ptP+DbrXB7d0hgyREKScqbSU8yToj4NiuJbHd +RnKIt7Rb5+5YLysyYixZO/0ZKBuWw0QCkckeCurcHbmC3NYnFCamzAnsNHFP+5jd1q029SqQyLV1 +0NvahjQl69AqhEp+yWVhdzg9vi9Nac0/yQzlv+TQz35NZlMqixUMBi5DIchhgoFmTBFsbQI5XxTM +CphomDp9WESoht63eM35BOKiOZHDeAD4fwzO3HY1o0RitKxanLxWebQLhobKKqZ6w/KEpUOnRuxy +XI3fAMalIh0ZrLRr2aKPZUEu6uO3tU/MZrOkyV19jwgPc3O+WKCB8FZYFnThawJHWz7u0Cp01m2P +7k+2XaYWYS04I9JsfF5TJ8BB/Pf6atzZpUomc1ZsNIzukyfUJHRvWtd1E71JkRHdl43yeu3IPtep +Tr4HKU60rIGGRFrvsa3QUoBRbkysBAkxYBWzJagLBURdV6IwEnHVihUZ64s1iUt2E4JNXnzWtQoo +MZwqapZb+DhwB+pitsHqU9T19gvpIVBXv/hIfLjr3yoQUFetSDKBLepC/866qAF1eawcmsgCXhbU +lS5RVzYmqYK6+LMG+RB1nctQBNX3UtRnAPMaGDmAtC+JvTMweTxPXGh8oi0HohedvHmuOBnip6d4 ++gL5YHtPLqAYx+K/dOgfNH7UzBBDHayYmS1sfy8xjiTKd7gMCFi1+6UrL9HfhVO4WdamZg0pgFbX +APXYdmOiIl4WFVZe8Uz5YPyvK9eeeJVG8d2ylxu03TAkTXTfh/BINfJmm8pDBWs320R6m1hiNyH0 +oqspimacSZzfcEyLHHH2Dc97FmRQPGb955ITmsBDEItHM04EQKTP/naWXSMKxhQ2BNPDwc1YDZMe +INPWJUAxRfWffi1GNdgRas7Ktw7/KiMhyLnmvCm20xzlXZGDUEnDr10CTO+3HmMHlf07Y97bTJcV +gAcE8HLno6vrirFKdGlTrC/JKDD2n6W8bEs5uJcERuZjtWwbHzhLzF/YZ8w7YK0JFBCwS5CUdGHO +KxMvIDMzk18NMAMZ8HBnDm2wRHcMiGqibfWPTwFGrh/sA8p0/BZhe+3iRcp+Di+ph3mplOhZnnnl +NERqZPsUFrkil7oKDF6ls2y0lEVXgwPoJs2zdcvF/qlrZxa6NwsQvPHwxKXf/y61xlyCpNS1lpak +1EX2U9eHYegOSl1teDVIw6vPCG6pqz6xnQZvQb3sDpj4qet/Jd2PbHa6IUpdSG/wNeY0kzY9EhGE +dndqSXoKKN+xGqri2IjPO9fy5iJnP18VhVIlpMchg67VNLKjfceP2hxPp4tYZW+TrkybKf4+VUaU +PwJ8OWbTVIMp5/CXXEY8+IheSmTHpaIPrliOdmH6tIdshOUaVB0Annog6QbjTKzbza+X8uaumwi5 +rjG5oKDIkfcLe6WmZnQ+0nydFsWimN63v/ykWpck5tSw2pcQjdO6qB2Y3iwUmjoLejJzbvuugxzK ++Eww09SshctwNjxa5unLfgO00Z9ckwsI1OtHwky5F8LLtZgSRCvbUjdjS6hoZ9YPxSs50R97OIpe +GbJLwRlUc5ecrcV1FlgGtZj4tp6BOUls5Eu6/18F857hTMJloq0lc9ppyvvJQJoRZ8lISFo5iDLy +2lE5PLpGehlECHQK2R/X6bglNLM3gRrTSlJAG5d9qBdBTM+HotOb7eLxlWJTzy8Uf33quEZXJ3aW +fUF3aOdaarYZThshD3Nk+waorBoQdTwkE2o5CuAybPbmgi88SmIuLvwhyFuSUKt1g0Sl9bbaKmG+ +mWgJuzO19Vl0Dn14nVippKvAYiqINJumCl5NTClgyQMttciT09GR0QoH4YS7GxZZI41Zcn0xsvdj +KEsvaltB95wdGUGkFasc8GguozH+OovB2FktapK/oMkinuqjNoO5qKUtwMptasL1MsJ/ELhvIpVG +BbLvNw2m1mV8iz5G5Tlp9bQgOkNdrPd/Vh4NK5f1gxgAHyx5Hqh4OZq8jv0WEMCWfO3d2XiQ2Qcx +KXdxjSTnyD+PD6tI24TIGKV72qtNXJUw50pgx6g5YseO9JgegdwGslNhNYKkFt6fC14vqPJvFZJi +osg8yCALiO7cfjM/wwotrgFDjslDp8iqRSq18hxR6jTmippqOgF/uo5/Nud0gxQOi83PFLJ7y64z +pLVph3Eyl8mkcur0eLM00NFUtRKXp4bUMsRzejJd6aZf4r8szM3vNdteGWlg+Y8WiWIQkEZlayvj +HEbIGqOox4lo9FtlhloXWYqyiZg8fyq3rqBKmnbkxl6BKLcozdxanelZFEXmFntOLIes3CoeTGfJ +k1Ayt5iCe91GHMqtuCcztwpBAeUW6gHaL0FMSFe1KEbl1iK+rNIfM7c6Of5ihA/lFmv6mVv3YZmT +AOUWj/E1tz8JFOVW3Q6WA3NrGIYKuq9WkCXVydxaNA/eW2JuwW2XoNyqQEkZbjN/KTGqbMXh6ppw +5xIVaCmSNNZ/VaAEWO+9DixqCJXFC/ret/TdB89DasWPD2qQ8EWjYwiR0mt8MQIDpVRUIHlFhU4V +HaGAeqtBbQK2kMyAF0iEUWZa48P2AN2i27iFr+acfQVt/rAEmvs1BrorsWnK3PnubNwiJBamZLUD +rbdEo4J4C/Tet7XzN6QJ9CC2HHh/ZP5UZp3WypvgMYHeboRoC8XbYIArTKDXXXBCCgArciX/iHXk +OJPi5odl44yezokKnll2dJIvu5T0lYFqLYBeQ6k2ssokWYgfq6Ujimhh8niMBbh9J320U2c9PMUH +9t5CkZP9SYM3vZX6pJRoyuEvfuv3Es2XTc9kUx1hedwyMaVBgqtDobG09CQ5WSpDDNW3uadYi6n0 +WHKjI7eunDKFNU0gH3PLFB/UHrMMSsWGIliwSP8bhJaVo1h8ESG/fAZiNaz4Clp7Nwu+x/YGYm4q +FE+MHEXCX1Y8A+gHWB5Kvwz0eyYTC/mcPKhQTM1R4sK+ERXT/iEX3gCI2+9gKxYK5Z3NAlLBRjWm +eZ1S0jIp/hUAlCCrjPI8LbbJ3A8OovoIhA8qH6yhUkhdIQqXAnymbfLhdah4TP/PkaQ2Y0r4GI7B +p+1mpn5fev4YO9swhXH5a2NW62LcBJbNHMvF8J8AGy9dHOgjIKGoXTDgpgvCgY4Vms2vII0DLZAY +B4Ie++pZvwN9ZCIBQdBUOKWpvPN1oKcYSJH/tmvVMb8mAqWErmrAhyRcBqtyoI1ulZEzVhMdaKGN +T0J+1u7xTMsOtGN4D0FH8sxa70BPtlmzqWtW3cjAixGCDrBwoB3b2Ij/zSNQwv190FbW7obrLDtJ +glyXLTqybJdoWdCUBZsiKwfMX4NO0JsyJgLWy5a8BVk54jSp8gqPjQa6C7xvn02MvgWTnuBGoVV5 +Ezux1KOa61ce2W5+PhynqBe8Ve5rzN7rbkPeTvhuGdYoK/EQ3ONslD5TqGnTdFFRnyGDTs9HXZCH +Zp0823FK03twWGqFHapowW1VEqZVHIfMs/9YsT9q2qQL15USr8isECPs4XiC5lkgTPnRe18rVAzE +51T2WL7K+ZTRS+LBBnbZoeoal/YZhLo1+5XosU2SqkxsuQJY11Dzfp3233uEE1UZ1IkyHfKC70AJ +VrSBqY9OPdHYs6jTzM7CWUFXQsJp54ECZZmUxZjF7EwVOZUZg7nG0njMlgUPvuXb/YvbACLM9Zyd +0qEVjy1TFXj+TNutYJunQFqJNhJHeTnB9ypB4nulKCPyPYBtBmFYymW7Ei3jElcb3rb4wfcaJIGr +ln6eRfa0Bi0na4uXYrkJxrokMGamPTX4Psm+WawzmL3CTGBrgCXY2QjQhIgNJasrgTK6SXs/ZTTP +WV4AU9ykOwC9f8XU40sLbAzstk/PQXukXZOpd1YoTMOrc9lMEQISo8Ig/GywqyJetZZY7hAlb4sI +IiIR/YRWi/dkcuWvjlCIIEGkRmBnjCmYqLRPnpNLpVBSsnjEnpxTUa01QJ2t5sa0hXicaGHhpFo3 +jMBlzy7P7gWjbLNk4FklFO+sn22nP1N3GaM7Qak671Ez38rgO6i0nOeLuReNd0tSjzq3me8QB4kj +oNFEzVTnvTE7t31ftHRQZyhtmMY3LEyjNSppEKLbhiX4wr7heN8bpqo6N81wIZ0iQ53jQtz0Meov +1HnK4qIWZlNV1NnB4k8IIo2WABffH7Fu6T5aoHbh8PIXOb8uWiv/cILkoCtMWBKpOcLII8jiYoN3 +OfEJw1PfBrHREIVTCV4w3crtRrVgQXYE9pYmBkyZ7SbLnnHskX32CiJIYfSTOzmslVE2X//8Jt8C +bmKtlD9X8ykc6JCtlaw1cr43UNNka/yWBcj0LBjsubcmbhwNApc3YqurdEjLldI1LGe9BeahWVly +RKoIK4vinIpEoJKZDozwmbufUp93CZ6XCnu/LD4GwQ3K4qFB4ZUfJU2766aCAS7UNAQFUew9YXLF +peXAWNwMuYxVKf9XUm0JzXJZ06A+9uXPTLeeeLaQpsMNVxfbPnMS3Pg94yhC85vESbvUIRAMLtvN +anjest8aYjaQJ2X7MwOtBCIUmv+TcerPTH5DswrfxJd1+pmJIDTrpZSqvO4/M/mhWQU+XKCLIPnM +RV5rLDRzFot6a8D5zL0TIDSzav/MNANgPzTD3uLmlD4zVCeFZhzQz/wxgHKGZrblKwtQwT/zeIVm ++QCFPrMv0oRmiFoNE9Okzxz/2tcbmstt7qIGsK7A7DMzQ69codmQSgZId/qZxe3VzLV5RmjmXkn2 +fmbxC832zZUrn1kAtaH5hVX5JSvm85/Q3IwNhZcQmnt85sn67ODQXAhGka5P6TPLtmWP/EIrtN2t +ossMrXEhYNCpUaGZy4/oJeH+mYEEPJBPKNIwt4TLCPoz62tUQkIzrpoWsNfPMTgrnTgFjNBMzVcF +8aqk3uzWn4poXIi2aaIDprfQien8Z7YP+BaRnAaSjjfZ8uGL+D7hYsQDOUP+sD+syGW8Rj/cz+9g +XwFVkNcqVj329/hTJtGWLRtTlXrU40fPTSxhyKGDMZMgTZshWL/HTvfbglW3yLYaoDnQ3A/LCHZj +sP9krCkgWEIk2+ck7tUjRgFhKA78bYpE3JKfL3Skl6Viu+HWpDqN8bfVoimJN/ZvIEfsZgZN81du +TcDK+KqZsGAx5yRZgUlgIUjjEtcPNnFL/wDK+k4GM3HfAmyDugpbY8bidpW+8a8pw8hdRB4da9ys +90gdVHaQHVqaOSsWUMd4sGM5y9RKJ89I3VPmuvtTmemFiH3o/BaBrk2gXsuxWPI/uCkanhpNZYGb +eLx73oshKdaXiHsOdCvGmkZBlSOBWYTF4oL2rxPgIcRjhqpDUUx/5PyHoto08ZT+Y3Y2xrRWzDrq +uXwQzZYZEFNUIl2xaeYF73ucV62KfXebvMWQpFnOYWdNs/3wAzrLWdKsxyekWHZ3x9JMAhW3KYrI +lmx7lqa5aGF80RmkSDNQTMBm01x+bEQWU3E5HLaxaVYnq1KrGweRZjE0zcr9tRBTkhOpsmmmZBOj +s3wpxViCXxvTnARZUvsg7rF0kmbwG222B5GMrY/JLZCpFEa8vxHuQ4LPGC4WmG32nxj3QbtUVp6D +pK218Lz/ecTysZ5qVJzo8bGY4V00/gevVpr6vjQ3cVvIUkyDczoeM2aQbd7/lou/yOXw+Pm0IDaa +tqNYLH0CeDfefEk1vGX8dbnArttVYkBgxeCduWlSyt7kqOZD9QGP+dmy1tVzTHOvxjDpNC+3szs7 +hBsgYTJrwsDyWjdfwUlBI1vGoyvuJ2e5wSxHnc81uFAnG2wOcrbgmJtyQcjikZEtYXgKBAdi3tXk +0QcMV/vMQN6T7M2BkcmjW9zIzhNDAnnnvW4Ig+bKo6tZORwsuD7qzMlwvZbrgTGWRWUb/191QxN5 +x3Kd/eW0DWYw0IRlRrdybjrzJO4zJrVLvHB74H7cYw0BhLm4hPyAeCz/PVuxrYXOPcD+ahT5q+sm +NfH2TwV/t5ySLaVloXI5e123cMo4YLll7wyJJ1ZzQwDgv6wyitfKiOoSTZi4wYbJgkcvSo+fMlxj +mZQDd4Jg87IA1jLxLR6kfgG6MvJ1xtJq4vHqxyhlbSmjeCA1QwuYAj5H1/giO+ehNoMpq+xuMn5n +Mxs31eZmBm5GsSpiDYKq5eQAzQCuC0bJsoUXHh0WbaRGYByF1rkRCBgB2lWSapRuni8DALtjfO8Q +CCnfM0R1LTF7yyqOQSqEhCSWyJT/MC/R1Bq7Huc2lu/f0I9r/uZxXyjGMY/W9qN8E7m7de1laJuQ +F0KQZyFojtqRkSdxjpp41OL/2OaUBA0dAzUctoetLeVj7mVmG36eNY/s2sK5JOsHx7MKX6+jKCH0 +3yrAXPo2Y6WaLXptAiRQDonDrOCZFOgUv3qWA90cVjVBwJ6x67PP62KmM+k6Oh2EgiyOU5Ar62zX +9TqVJzJhz+fo4YrrkNwPeItcll/ehWmuOyfQTpqehnU2oOj0lxjnmx3J1wBW+Ur/VxyRbXyUmNEy +kVtFjKBenKnMKgrx6/bMMAhQCvcOjVF/tmyugammzLCw7ao5VJotPT+Fy36ARSRYWdpmCyc3wEGZ +A3UxXLaTzJfr9bGXCa7a5Jxfoxt05yNoe2PUzLOLf0dbYB1jEBMGc7iyBWU8bpyzqxub6xXuCm8c +TPkx13TuCXOjcPieUxj6YmBQiK6LGe460Hgsa1tybUd8dYrYc6+tVyQAiFONwfVqrmawJCLR4Hkm +pKQXwoegcyBm6xnl7sRB+NhZi2tCL4O9HlUlH7zM926huZM+/lncbzu9Amej2qxI7OINhQHxD0J7 +B/lH6tE42cKCCTDBYGesFZjvWRdApuZpBiffInaM41HrL+xgMSZeglsYxGrnLFcF2C8WVeo4QygM +AgtwTJ1qujd46y5xT8qs1uEb3Jmx1w7UaxB+GTQ/hW7dKyKp1BcNkcZ/CrM1vqfEtTbO2/h11Ri8 +1y3ETfeR425/3eRz3YVIONwBLoUfE9HeP+RvTMy0UcTqcNcNe4mPNwETT+2HP939SWxL/E1k3AY7 +bAgjmXYFOyOAEYmw/nayL7x+McLCMidFZL2ZCGwkrtJFwtW6tL4OpCSbt37fq+4SHoAt78jUtcLy +7YWvVNCYqUGWvyAn1XzfvMSzSLKGGuqdpIj2FazxzdNcujsjCYtcUTNFHjYPPt/PnMZmtlWTOXSw +tIc8Wy/JJnXN53drKRROlSMoDHrrayibh9RY0oykGEkCVn/EoTHlRJ2QBRLwqzsW+pisKyzqujiD +r3k0qOsl61Nks0fNdgfxh9WNtbxl/7NKsO+mFF54gfKB8OSrDQofhN+JZShwRSJCZ5rNn3Oa8ICg +iqG7sQhfhq8PU1MS6+Uk4rvfyP7F5MOYy4CMAoqXiK9vhdj54RBGTBYiAUSW/EJq2fYQHG8kEeVm +D1N/iRE3sE1EDLaTZcKIYFjtUO27oO9ii/Cxxsc6ETLmSaSnBYHlMwWC6/uL1fBlzJ88viDPQgZ8 +6PnzI4dBClHY1XI/JyG1PVU46db3JoadOcT0e4OQb1IrTvR0AY1vz+Q687xLzN0HPNCJgnTVxvOr +jalyG1MGhEPGKjQmB1oB74AtHQ7c1cL/uBIVWoTZihKRm4rpjjHjIWznlx+AsdTDsoW8h6F0ikQ+ +nCQVscVZzSCy75n7eZgzW62Md6TN4WquCxkoeVWZFfPx1VReYP4C7WI1afyfRlQj9W2eYpIzmZdG +tX3jIq1ue7BwzM1/1aphLomcJzWvmnWjp2l7GEEBvXMaL4K+F++vTARaFT+jk1N6zuimZ0hyivPk +GI8Dg+Bqpn0xy4QdmVS4XisajTRpcyWM4xUMrvXjMGCYInK9YsiQaZTjXNafjzIQCRhJ1pPn4rh4 +wSdW9F/YYil74PF6Pb5GB+Yu+9V+V9KXGR45BxLq4q0h5cbVc8wT1Mikm2g975qMHvAbKuRX/4nX +LK2m7rPL2ErHFrtez1fyAr6864j3rM91xHxscTDkFpXZcZCYjGbiVRUBj6+bG3wDh1xOQrWsQ0E7 +ioymAgAnNE4ODd4vOHgoMynToBv2aFgsO0XY4LFTqDiFZ9uCB2vSzjCMg8qPrW8YCRm3hyGj6SpO +C4vV1SSF//VdQe9T/tE/zUadqPTR8l1aeOfth+0LJVqyAVPHVDmTlMK42ryhPtTsx2pH8wiKv3xW +fzWfGnFWSkHFIe/fJ827YJVoAmMHgJDhEWdDOo02fnhV1/gLkftydMsyQR5qPfLjJ1BaEiFiryPm +mJ/FcbYf53zGGTppG+CtIb/fPOI4wPxcIlzTfGtgOH3faXaUFi0DI/N86QCeUxpCclDKGOa5nuHn +LXofE807tMxGlh/oC2bjdVTu3DJz52njtbdF13LdY8CoGRHgJaZNFQI+8U+biTZJqJbFUJlkDEI8 +6MeIf2yA/AjIiMQcD4g+zEom/hzOeMkT0QVrHJWTka+MlvXb2oUsGcP9X2IgI7E9gQmBBCVDSl7d +knkd1qESBgfyZ4JODH5up1SCbtR+/gQtJn+SBT/8OHICuIRUWK4554L8kJCJfZovsxJtKxWqOsgM +hir2jdfEvcBlvoSNI7aMZcTk9XBidPke4htuvNdRCxZMtiNanNeJp7wrhrEatOSsrRitxj87KzlU +A/mB3QPNAhdschsPqByEslMwXKdDc8cEDdB25WNc3d9HJC08bIp1dIodmrhf3w8vs46PD8hU+p63 +kPCxvGKdR5PSL+NOYkuvM4wdjEdyL+M/zqDQ1pSvGTMOGFviS9aysmwdJ92IhAi7EVdlHot+qEwM +G1r8LLcZOq+tTEwcNBjIh2u3v8HC5r8EOrs5NZqgedRZK/WBdGYwCxsCoch8Xa2HUT8fOhH7/395 +LfcUci8gciTzJuZcLyi2pvnNdqF+1zpenHbA0po4BtD03tOGg3dmpIYgK0UIVPysEAg8tPXRqKmh +jEGoYpiIKg4Y6Tjl2Y2qn2y6kM0645FH+3e98brnvcWgxgubRJx2l8jMDyacvR/uZ7PvYvwnG5Mm +fJWIlhkjjxcT47ln0AwILj9/gAYsYrPrRymQVJx+cO1AXQ3zY3oa4Y9Qhpjj7LHW59nZ01Xevmfs +JLJLINXleYjB+1N8WC0wL0rZoy0vPyfgIqrDc2FMKz5c1fcyT79P2bYYhBlpru4w4nKi1byChrFQ +RlY5j79w7GPeVy9UvI8Y6NIpB1M3BRi2MKx8mBNYM0hrTpMzT/0k0tji3VdT70z3vAhDGrnyVE9T +QlDdPWcCnyC8NfZKy5epxrhryEWiM7fIDtuMWZU8ciVE4ia03scRxPoyGhn6e28wRUKJwAHQkCSN +JaXyE8M+9Od7+rWJ8PCDhTGptQNNs01aMr1DZDGOn0K2fU1S8wTb3aZp2qjZcBa2izuYt3Eo0q5t +Z+F36AAA8se6zKDmAzkXUXf7dhwexmXzux3gkkDVdhZOraMyoKrienTV4GYhffaFchaqIjdo+iKB +t1/dW3FmDveaaQKiEW92b86XhhsrjdGtHczFI1Df+78HdpaF+lHDmxhGSL1p4uY6hP4bvQMcwsCd +eftO4R7UqKMiVIx3pjbbJbFGb//vn/qSa+2z1Aryj0nttGqDlwwoilEX/0Mas7ysans8YRSjq5Ce +xlPKeyu354HpwdkwYeexdlWJVAC/e+1mHDkLEWTsy2waLIgSiifPxN8S39DpSqozvkGPF9tDriSr +tr+keYgn4g3wpmaWb7bEMcBf/GOP34A5WaP3yZ0HfvaOkzRjBcF4GBjzBQ+GzX+Gj+ShSWwsnx4Q +/8/6sdU/s9gvCC/AbZXMVj45CqjTjqWaEHxc3GgrUxkIQdpRXmtGpweYZ9D4jeRh0cceaaRivoaT +Wiz3PJ2oiGQ8S75/IWslR+GJesJaN7X/l+KipbpaGrclLAslGhEM2CoMguyN/y6jRguXmzHM4QKW +NsdXLy1QrsxnPL4coAigVsyscnI69HzilRkW+vsNApRX40OntGQpX42MU7ydbJmxgSW3jpLWNVCh +0AAatm38D49K7yuINJMSZ5Lb7Gcqqk/vy3GL3QQHgZbeQ8Ttv+W2w7/e646phsN4BUIo5pMJUzIZ +hMS7DiLHXdaTE0oPxlX94q7yUAvQWNT3edCWzzXDAfO8tai2zoGTH71gbB5qRq9ullLyqVc9hlvI +EvexRYZsiDBWRY8cuX1X68NTulKM5CDgomqBcAL/WRMX0Aejz8B3qUO0XD67sfxePVTY3x+wLjGR +0DrBSRg3LHOj7Dz9mYDQIxKLMJnXFXrvB4JVnwTQE0kU9JKl/dVmyZnzRETJU73yGT+b/vDfCWZS +EXETLZVQUIE7JpMeY84rQHxTRPV5fn6IGTjGFANyNKraI319cEjCGTEQg4Q4AShUvLgza6BLL4n6 +dTh314ZCnvFtLpTAN+bqLcLrlTn22m7BWL/xr+3A7EiSH4QKyKhtltPFWZ2qqFOfdgU0IvN2K3tH +KnoLiN+PeK/HK6ehNi21HqIBkoM4p6Yow2fN4rW4w1ccWLUeD8e60K5wBgyhjYoHiDRqGUBy6AMc +aWpc9yzNoGJdW7AOBlwMlUAxDEhJKsTIP0rNzz575ei7orQm4ExiGYV/D7YhcS4qaG3RKy4pp1p7 +W6Zc7NfGAiwVg6b3uyQOiOiESxoKZnstbG9ubXBf/iG9nk7PzlW8ABlvelgerb2ABsDHLpIFrKSF +1pfS1Ehk7xdwkmno0Wxs/zRBWbmD77moN0Gv1iFLFiaS9CzoU7Ea1wKyoS6Df9KOdE6jabQYVmfI +y+k2srI1CTMPQSINC6HQIMgQlgNULbrYEqY4AwtYoz5zJdV2u7cQ0nC4lurf1hW1zVK4DoHjq9+j +jaJT5PiMyExcdannMHOCyZat94OoJctCWZsbaMn4potzM8rcw4A3yUM/AVPUbBrsMQVpnuwkyqLu +f69mddO/MJOmQpT54k6zw5RONisyGQh53L4pqnvOKvKGPSF/vB5aRC7vzJMkxj4sviavadwro41I +CRtpTEIuimlp2VLz2wgKTwWO6HOj8WInj4ma8v3D+m5RibB9icE042ZZF7lV2f7FZHFwFxpc2YZv +yA7SsAh7Kua1CXn++gVhX4GPFG9yBSKiDHodS/UJaOywC7zqqr8qAI+OAZCCCtXm7128jol0OyqU +0flqqWBxzKVEtfyddZuRJT9JQz3fPA7HOtLmADt+efJC8mmiO1uzs7koAWFCptMxrybBKcv0L2ys +450cgTwNixwktWJGagMEs5DMe8gb7/f+/LnYCBiZCMxDPsQ6dnU6NGFCthABcAkISqlCX8LwsupT +zGVwFfmWsAMBeqSyxFnBVayMiKsYTXk99L2emgMRp9B6uqyPA+M0ur62wVdsd8Yk0Kl+6uae76Ow +gtM4NUQiXQtJPCYQXTMvhK1oHNUYXD0ISZS6JkVOYjPD+XPEzVun/kEkTvGKYCWdv1YhEshcx3B8 +nQrbQyPBh+U5pgDIlvZFKIPSLn5V1owkPgdr7mCwUetMdcTvtOmoY4EDPJnsnGvHvB/VscvgAkZs +ldJWogSJMMiBLGgoZKKpvLWWequHzsESNY5voFehdY1wbtucNH4a5j/FYIZfz1yvxjtg+zKyFUXJ +Pfoq2XHDxfed1prWzoNo3L8atbMVPvpkjGakb8RDeLvvVU7kHcGMF3RmWeaF6X9fe8aUMHn5LPkS +GJMbl/lrNUzhhjtKSWDfKLigJBNfx8/5GRaOkHEjBWNf+y+gl9OolRdCTfHCbZIvQFpXL5uROkpj +jymxyemavo9bR9OtSqi2Iv+CP717Y+2R8PO7g5OdyI1eibX9N+FYCggN1GgQzzemi5WBOmuslSWR +LIsv++d0NZuzjhbwuUE72Qnjj+5TurTLQAEr43+U4A16V/kopxuFtFPnFMjN8eJBQH9dcxUEOmJT +I5aX3ljUGhB295Rj77DyuAQSKKCuu2zYKiZxrWWXgMH7vsa9+Hjb2ho7Q/iK1GSRfyyHj+ydat9E +0Hs/MAFLZKuZ8qpqvLh1jVriWqC2Q5OJJDxbqDluUJaLFsu8BZAwDgrLSonH72pA0Sqqk+7U1Eh5 +XxmUjf1N0XXXzfe02xholQQqgLd/dPptPTykH1wI07ASY1AELnSg4Gm1KZnHml8uql8hQcMl9CRV +qsMe64I5XngeZN7AHeCKW0xdqB7eu78rpZMNyDsZlgo5PZd3jB9bbZRDyzKyj1woTppGSejDh9Bi +EyJVAZc6ERLEJV5NOO+QltukFkJSbryLG9ONap1mqAz0CWOPSqe0J26NfTx/fMPzzZj0J/GZCWlR +ebaykXf8V9vTa5xa71y6gZMfn6YLrvcaAceZU6gpcb0znu+l4dzyUU6yfn97xldrw9RZWC8MmiPb +aiAH2UdiCvI3wJZcX8jW75l1hoUCm/6H0EEYYGog9jAM8BMstE4o5yMD0z0P2LgETyc4RZyNnjt4 +XOBv/XGSfrBBuVvVersdLS8cku0dL7D+t+pw1Tu8wBX3gi8BlwhFF2L2zvfwFfCWWO/8ulWrAxOI +Gl657RSpyUrDLYd9W07sMNzh90Faa6p1taVdpRg3vyyTGeM9ZeiS8yR5P0O+SAnivz/cf50Aol7m +DYQwIqNZoNpCqT8bF0q4bQ24YyLe8VkkrmTOgkPpGe1nWzHnPinVQzMzmI99thLBkRejIoz37DvX +FdYSXVP6Ed9M3ne8iHbW1jmJjmajqrDka0dKi89Fdg+DM1N5ULDs1nbJ2PuzUDl6bBqV6cE28q0/ +pzQpbKGlpGgJhi+5mkUQHI2SAN25kVTQUDAEQAkOkdFnqWW0eNL8746/m3mXv2pFWJf6uGHFO4ee +RR4MlHNX7SJ8yMkfRv6FCdd9R/Dya5Qs0y9zhayr8AZLtiwQY9N8dE+jSvpEtHmv3mG0EcFRiwEL +1ER+AEkALb2QrZDOUtwR8ZpbARZ5wTBfFWD5c5s1C+VJ0awo7jWasyglclzoO0N6ZLGKrbru0KUp +bD+Io6TXjPGdH1OYEM5G6ozSX46dPS/H3Y8KW47dthRLUqYMU4ReCjG8LANNBA7aX7PHFhFt9m7g +zGaPSiy/2fuBFrL39PoYmb0DetV3dP5nLCzS6AHrq1F1sEEcUMWuqdKMJA0qfZNOrUb9wranrDl2 +8xauFK7UwLouuIX/cLsrW1BNHJ4whAXGMM2sIWiiOvxuw+Hqd5jB8mjYh7T4AxQCEkwQJJIQVOmg +FIJ8EwkieQGVJnCvTYJdDzAA4SKASQkW8AlBMHkL3f7gX73LLwxBgA6u9AsGA+hBfHXVFPh9U7IT +IYhd6FzJfEPgxcCGcZBMAFf5njjiFR8gBR2dpWhnPox9yIEPlkEFphHdABSe00rKKIA8AhDBYv7e +4Ic2fRTgZ/eehqu3pp6qunz8iiI8RbtT3lQ463V5fjd+qtH0F/yzouXtC90Qs/m10cIYX89ST5f2 +elLJQld8vm8+haR/NLY+XkckihgSvOc99DanAaqbgIZPMxuI3nYoWeWOtcwVKePET8ZTR2QsxJjC +l8WI+zBAsobJLGFyXzA4jwJzZn4x5vji0fZS7aAPTy/x+bzkdJdY2AX11eVczMVKIheXe0tfW8TG +FqyMtUQ+S3qzIMS+eeUNWVTEEmhhCfoKwHAbK1faVivitDyjztOqYCWdjRJIxtGrmZK9lLBp6D5k +NZDGXpDCeo2gRWxD4HEkZSS3R2FYu0HebkqalKoA64pMtUC+JkE+VOPVwaMb1tjpPBqhi3+fYF1i +saTOE2G1+WxzWfQHaaBH4QxH+BrB4rA9rTXkMOErDoUbS92i3jlkehK49tKPuGbjPcKYivo/hWc1 +k2Gp2RZgjVgASpfm4NTLJ0Ls93UYpV7CWCsDtv5VI2n+Ezj6ZWCsxVnqunf9ZxKp2eKlCV8K+/9S +36ScJRkrvqIwijWpnw15tD0NbUxe22OuFy9v70rTAMDuvxLWYP2mK+EGPRA1wu/7Jv/8jwUcXRgq +cJgnd1nSP7CNcoUVBPlQo3tq7yvRIXstuWAU/zQAPABsAqfrV2cqnx1KkIKFWbR7Zs8a8jFQCLjf +KxArpSrsb9UYT4ZEds5kKpz6jzHVcn175CPF6z4HDlmf7G9hIrfESY97YwAYznJWVYOni0YIjSkw +I4kWLwofNB4EX4v/eiKu5hPSF9yfNAiVBqcpujyae6U6bsdwLeaVo7GvDPgP5U1mrvVEraWJiSQ0 +HLjbzZ4ky/GlapfMnp4hs3gM/YdSFzed/4kHefNva/+M1I81qjW7f0yuSQ5inXO38mz+65JlbDL2 +TFh/YEgHN3OAXFpZXyF5AOXUOyTr0aFCnDGsFF3DXOstBTacp0Ely/uzvyGhQ2n414P+M+Hxj1r7 +uyb3y00aOGZrxObxmfu2jFmFbiUavoM1/ndexIyN/l9dtUh/AlKwIuTGp0j8Cw4dreZLx37WT8fA +5E80fH7F5zTXSXQL+NkLEDyyP0h4Jx7y8vde1k8/JMr0B4k6/UD4UrMf+Z9IrPkHPWkBWm6FOy+Q +RC4pHQAAxqAKc4wCuqWIaPS/aG8IG0Kkd3q3lWQ32Q385yQ2bM8kAgAAAQAAQCAFPgMPAwxMStKB +BD9ZYeVDskJhBWRlYxMOViQEaIU8+WyMBTmegyQfCh8D+WwgfDw+J3oSglECSjBkEB8cSMjGKnAk +eAAqYJGh4T+Ad2Qeo0ykkI0JOCCAo9E8WI4mVz4YbJKCZ2MREDwb23h4PB5OziVsUf9UXNhggEQU +XESGp+QcaGzsBQAtI5gRAcHACAkCFWxxaJBIdDRIKjQ2hgGz4eBg1EiHM1LByTSHw4UCQoph1Eal +lIkJCAqNZkOBDAgAICs5AdlmQZgNSACysCGCiorEafhg8ZSUZCCBFw0ZHe3pMIx6C4/FCugl1FGh +4V8bFVYn5UIC3YAAAIqIiIgAiSBpwQFEEhRwyICIZDIgHhReRDIhmUwIRmUyhOjnI8GGQUBgIjKs +UCDAqBAICIyCwKgCDwUoPBCcvIQ0FMSDwovIR0uClQQbNXlJCxb8LIgGEDCcREiwiMAoCyssFSRI ++LSMoOZhJWLDSWYFZGOmQqJxECCPjTXGlMECJRkby9D7MVHgSIcMlvWEaEiFgwNGbfB8XkIYkw0J +Cw6EQhhFAKWCgo8KDYvn48APlinA2GCXjYUnhCGDAcHiMUIB4kBjpAMCypiQ4KWDYc7GcjAaXCTH +wUmcB4aUtXCoAHHgBZOJDrNoFgabDMW1sJioUIIejxKPRQgFRmGUh6VFOkwFhQQ4ISHi4wLDLFAu +w7SRQclEAIOLFonTYIOgPR22AIMCymGUBM/IWkAgAgwWHQ0KrEXITSDQxTkabDQkMPlcJ3vjDFB8 +OkyDLBnEkYmJBgTy48QCLxYgcDDBBQOIYHLBh4qDzXrJwmHUBkrFHQhCo8MebCYwWPAFCQQKZbBU +yADBgJKINlEfKk4BWCIsPBt7cYCAIB1ogDYNlqPZUGjQOIyaaLAczcQi0ThNg4gKDZLz4FhHg8St +BcvRIFjwWdtwcBxUFEAIC44GTEfiTDKYxC0g0NnYbCxHM2GRaNza8APSYmM5GosAgoEiEhsais3o +bmy1EPA8XNish5WNaVZAWDyZAh+KURi1wlKBURg1MnVAOJYOb0+HeVd8QOan0WEUnRWQOcUHhFEG +Oisg69PosFeNDnuKD8heQCRSMiYkGKkY8BklHAgeZAAAAgeCgEopNrHxEBQpBrGxHI0GkZcNH5cM +n5VPyALoQ6Lh8/mwEKGAAwoQMOHhJcQhRJIhxGUlBCRjISQECgER2fAJEQBSEsIiEoISwmDDppCH +kAPLisdBxIJnQwfBgIgWkEfAhNDHs6GERMTjUJJBEuIhieCAweEFXyyAuIA0QkBEQkIyfGAUCAEK +LerwghRCMEIKNiwOIBsLoxocOMGoDRwXjgYnkGxQkw2SChM6maP4ZIhCBAPNA8hjYww8UAzcC4ZC +Kx6dLJ7lQADAwxrZ2AUZDAGPUSMZDYVcHJzJS0hDH05OBCtMXkImDgcwERYITFgQZhgVgnDZ2IYP +CY7GNosjouA0GxIczQRHRMFhAwmO5kM3CGy8DNCBEwgIaLHwgeBAxWYfQAaATUUIVcIW1aAxoaBg +gg74DJCPjpGOxAAHNnxjfVo6GQYYkJqO50LFwIA0lgMdCTBqYzFg41aD5WhWVTSuwXI0HioDHFCH +gKDBwMQDgWYoNCpcrJh0NiI0R2MLQE0icRiVkeEBA8bKp2UEITqcjWngeDi5Ec2C5Wg0Li9xHJkF +y9FsLBSQuE7EREfEgiSFAgWEFDvQHlAoggRHM0GlFMt8Ki5sJix2AwRFin08nFyB3o1B3Aa5mFHV +ZLeWJV7GvOslXcV1uSx55a7XRf313f5/y6Woalot7UrVP11+iiqdMptz/7Pkq3Nv6b0vvF2c+yth +uqme6q02ZV+WjGpnqia+tSip1MtQHx/N0lb/5f6Zda/9VNNfaqL9f0qn675t6pWnm9J6U25NXjRD +Xuup9+x76m4q7UrTb+XlzYzYy/6PudCU31e/NnbyYknC713O+bdtaKa4mvGXvaSLvRcy7jpONbXt +6q1L5urv1mfmtnoPFbN3U03R+pZUs9G8/11SW9u3eX23/3+3bJ8fv9v/v1f2Pmd2o+aZNa9V5jSz +06X63pn8/27LWVNdWS95GfsPzfjXp2eq6RzdFVniPbtH/2cJf/XmIi/3mxnj0sz03mb8/+bFm0pl +VYqSHJP97cwSF262rl36me0ux8RXXTezx9WYaspbxjf/VVKkiouHmJ7HfL2/NFkNsVd1qdTNvJzr +TknRri7EyJSkGJmalkmd3L1qq09JUR76/eNC31fVpefn7ojcaw9RSVFeZEqKFpdGhrrOUbU5982y +bTXm+qsr57Lj4z+1114ofbLd7+uvPUdrRtNT0/vyvFyr1//8qLzOHrOTbamUnIi6/cv2TVUvVyMj +IvZ6VVd+z+TFi/v5yG29bldiY+vE///Hha7ul8/Lk3N1auPuva2T9lcz/6/y6/83LlVltlTm/z9b +qWqKEBVdn///eTE/9qGhL05rRvb/z1+vvn1r6bxc1RTpv6W/rc/l/F9DM/tdjLd2lRR5Lyqn/5lZ +ty+a5tJVU/SLprxnZuzLsZ/1PM3MNhcfLq5jYzVFyssvQXQpSlLcO2fP5a/XmMb/v7yWlRS9HZ62 +7i2LcvWdz///fjGrKbJjM7O7xVFjq7r9/xsua6US2bVcRKpeIl4U6CIyJQCQyNRFZKoEowy4hC5K +LMqUmBkvIj737Rq6reFrW9r9m/cbbuaVYElEESXggOASutiUYJSCi8hUBinKYAShPlLkeNH3Vcig +oOQMKE4BWjCqTTYM4yF0kRgD0sTkTJajMQcGLhKFR1aj0WHdUkIgH8PAJIPBw0SENsDJRGREcFkF +gMvGUCrcicvGICxO44zEZWPTQgFxIbB4WsFsCH26tsfYdxq9Iut+StFZvjPfqWt3ah/q6oz+M/17 +1SNudYWi6woFfVkb1yPU9OxeS+N9XX1+5cvXNfYo23mzb3WyR4eojC95u+pE3fb43c6u2fa4Otuj +20Z3727njI79UKVI5qcmbWk61WV7E6sU5F30u8Rej6ikqNkVJ13EUynMKKH2qYmXPa/Z7tCzz/HM +WMqd2s9VitSHasqfOPm5k727J1NdaCpt3Lp9mNj8aXmnpOhZLZHzUMpTk9/f2ZkU2fZqO54ii9jS +7/QwGRmX/e66ERVbsuWNSkLpJm5Mx0tGlxwZlR3dVz7uY6PlP1r29qNk6XgmXTpmrna8k/oylz7m +Ix+mpT/qQkzFNDWUNlPXnV2qKVOzL/dSajVX/P5fTcQ8TM13R0dXU3SGuvx+MvRVc8zL49x/z4Xn +aoo23fzX1qV+Pm6n+nN2rpMW3dxcTSgZOn9/q0nS82/vPEc3X29vNZlLaf7dk3lndzd/7z2/tMg9 +ebeSzHl3W00t7dLex5UtOfdqa6tJSs7OVhNvb3Y23+LtXanXbWNjq2kZsSdN0+u9/tyUlmZfW6sp +09faWk0oSYn/vPxw+/oZn9V0uPjOnU9i58narHub1bT+JV763PyS+zKryaX093NpV7evXF6+dmVl +NbXEjHqqvHrrl5Wky3hPzTh/dS8ZWU3ryos8qaYerz8+VGlXJ688PlbTpRsbqylZkZXEyGh8xvZL +UdJlyL+/Xu/+fdV0rH99jM7Gb9zsybreq6U0/t5G4/7s7VWTdI/LmXlX9bLt19mR13Edq+8y591d +NUnat6+rpl3ZksSrquqrWcqV/Lm3kKXexVw1nS5e6dft2qfj9rJVRlxclXaZ8u2tmlJKQ1/+d1+a +e7n6qXfL36dpqybddcbMx11b28ZN3P/lr7cQV3v16ePzHd66t3Mvu2V0dVXT2lpbzfWvSuJltpv5 +l2iWv7i1VU3Sr1ue+n/qO3r2pRniutb3d9y1rPyrfVHSla593OZodr5+dVVNLvXqXrrpC1fVxHn2 +/n+7uH1ZY6vearWWAirDQwEGg8EokMqJBgGwWGiJQCEWlxZQSCUCqZxoALmcRKAAnLSoqBAIw4WT +0IOJiRCIACeSRAANLCglHg4JJRoRPiBEKAkgUQYiYhUMVB+MkpLnuCgBfVgqTCGWlAccFx2IqASj +BnAh5HLCAAMjMpWCgYIR4cTEd7eaQCKciKw1sshUMKKL1KKbiCyyzJ76YCgp0d1WkTW19cwN+3X3 +7vR3EeU09dt582+X71r/zvvdf3PVUy3fc08N2dscex23195dd+6itWZH39O9U5Le3S69+2V77kNB +VdTttLw+1HtGt9d2S7S+TsSPqIffq37XZ5pL7/gznW0Vj+8u9Y8XdfrEe5WipFuTzpTtOqfcdo3K +3srHx4qNypLquJ3hqmnfYX6ee+++qZ3dvd1+mtrperu3Uyj5vJnmdm7niriKbhclKx6yuW/qdSd1 +V7/l/ea33aFs2yE6nq0fCttp9BNbqhQl2Un3vMp/pme3/K7r/t2u7XbXjnYKpdEu7/A+faWd2pna +qdq56nxy2qWdQkE7tPsO9tnZ2fHZ9akUhcL7zG+9b/74/NHbav4h3+prNEPudl7d/J7R+r5C8WTP +qO8Mz/X/U9cu7quJc//79xKR+68/DTNN3f+Xjz/i9z40zOVETv3N04+S3dTQc/H87/ws1VPfcz33 +99k2+ZX0+amYtvtn/W67n2v/f2rsvG37+8YS/vu+6nxGp6amUeorlP3I2TZ9NdEU+X2f+RXKO/Z7 +v0KJ9H5N8XPRuO9N/aPv20M/M32VPPtVh/P7OXtXv5qid85+VClKsvd3bT5DSXUiXzvtP819j16Z +f/3MLLJlrzqRrjvfm0ae9lFmqnKnPeO618gx/z5aVUXzs9M+w2c/U3xXz/b2/c0/w1eS6kTHyfib +n7fwI/9L01S0f71DRWM+Q/3z01zs/0de9/cK5T7it77jW2Zv9gol2jRzZ1N7je45vS357KcoTbef +9/1UipLO1n3xjLM3c/HWNtU5dc3Z9iw99U5fdS9VJ8r8/OXLftRlw/TXla5Qole93r5UX21fjw49 +D/tyG3v/X6FE3ajRXzc+H2qEm5h+rDq2/ajcp3ppmdl4C9WNW/tM1aPrTO/jjC+oRDDq9qmmpA4p +NDMzAQAAwxIIMGgsOhHLZBIpenYUgAhRWCY2MiocKiBGJojIwxGJJAYxDEOBHAhy2ByBNiAAyAL/ +Phu9rc3Lusm4Txe/VMlu0perH2P1FGh6m5Kx9wHdkUWQ9sjq6LUDUFeXKuy+LETaWen4en/9wXBB +K59m+HQl7cJh+nx60VdnT9cuQqQTeKinyHRuppVRmIDt2O10duqSS+ahiy0IoMrgivKOFdEeHUYx +Jv4aj1ElsS3a0exao3jSPcbiqsWGXWLOAjWCQJA2a9eMi4l2xpsDJR6DQANgngfTUAortk8ZaT/0 +WtuO9aMBFjBpK9uIHhJggOiuWfQ7m4SPEzp6vAqJEyrWQ76dKE2XRS1gGvJf7D3RyzCg8Ios38Jb +4B3vYII49IAVUmCc0lYKc3NIEUXI8ChNfVZwlnL03Gom7OjJC+hEeK3qQ8a/vkFD+X/aQ/E4aSOl +mINMVXyPhiuU1oFY0ZqUWP7IPBcFVdPumhpw7mDDXFkhGwQ1nZjMi+8g5nZrBZRnP3VFKklKPjcQ +jvNmPABhzV9msb+/yQQpwIfCPvOMpzLz+b+FmHlMfwRrcrEDxKf0ecjj5G+/rsivbm2Zotr2MqH/ +bZ1+idEcL274JFl1TKCkM/HNWEOlKT5txM11EVu5UousXZ4HLoJhQKIiAWUkA6MbkM3BMClG0Sz3 +G9BtNr3Og4OwMvOr53Ege3IuTXGpBzG5DugQTRDioFyM1JUR2qLOxDlFjJ6nh5anoeYPrAEJDd8E +rMyf+Oz3Hpo5+P0I/E5XTz3KANqzTOziaT0K/BskLA8KcxAgEQTwqXyFxl1jyTpQEN8oPrM4KJAE +MTfoXlg4Hn/ExaBGia+ThLneqlqWQ5hwc/koDw0aytr4Cphm3mS93nNIc91bi9jKV1mr53i8c+br +pWOLeUoijU6l4lwuGT04uRnQOxcik6X+isOWmKn4R6/X1Oey4hE2sovabqUg/eChUj2dOR3BDklQ +zDKbTpDTzuzYeziiV8wFYBcUZ7fhSE82vI5mcnanw0hjYKe1tWFHbmneT1BuJB15Wm50XKbbA3Dt +dAPap/lYnINDEKgIa7xRQvkLHQU2sIXDejmo2v94hok4VuA+Tr7r9TRBu8alcIEWtTgMsjQMSeHp +NUAWv7aZWw6juBod508E9ZQStq9k8Chg3KFFLQClQtDAyxMeoynAQE2dQRqKcVPbGDF+FNM5wDTC +Nruarpp49eTTZVAc5SwZy9jisWYdqFOgXu5bbtblJWk0pxROPYCGzzbpoXld0iFoX9AYktaxYV0S +O+gn230jTR3RWIEYXsHtNcePRgb5OU1f7nGxkFMBs91TbTqiI5ERs6FwkfpisB/2CZA235B1cfd/ +p8vwP1zt5nUGZCWWeBCa88N26auJ6ZeimSZmZyiauic7LJSVDCZnB10m/lP1mIaU70kKjF/fRufK +urau9iNlrY2zieVIWRyG6o2wgDk8zEq3vvq10pJSmeSLacRJjQ84mkI1tMBVtGqLGlQeyl3yQQrF +qRPI6oxb6CUJkCG6KEbQK29PdRJz55CXIzQ4iDBNT0oiI+ltQCHdLuGzZyCGdRO83cEWVM3kyjw7 +hQ/PRaT4KCEHup2ID8qfFq05pnes94TMSLgJLVht9zo8dz3qfJWuSXkey7Wg2WynrHXpLwbkgq2m +dtvt8eYKtQ9MLxRFvDLKHddThfIC6apyngQu8fEoJhMSWZzvV/tp/ECeXJoPqxsms4U5ZMdHJrGD +/cnzhPT4LO34OgqmKVZCqiT/Cp6DhC02+z54F81ucIj1qshl2XdqWrUFTMM/8H4MAp9/cXJiRmaN +ZvD4oMfYyEysV9fi4zqfu0fiDZAOdizx8OxhLBfoYfXnEuh+wvn+ZeqsOEP8LCF5dRkZgcqyeZMo +n/Owhwiwp9hZSf2iFxn4kw69ECuG4K2KsZDvbNipaz/JnU6URefnff4XqQZKUAKp0+p2XVqoqrU2 +kAdswqHgbfRr41iDjGUpcf1C8diCyTJgC3og0fQRB9THVyXRGDcPhHnjdHfCtPgZaBj1MmCsJVE0 +Xo9bRWbKKEs2BchVxXIULSioaKgYo2mtNprX/+a25qjxnLKMb0EIDpN0dT3GbN8eLvxGQaR3Lomd +UAIBOQIj7EsY2R00eXdUPG+dhGW0O8d3OEeqmtD5bx7eBmi7JIl1fPKfUVoRVgdjV9KA31jHM/Wm +nfMz3AxnXayMK3lYZwlJq5xBIekTReCLrwrVykyamqp2YMRWNgFGURk8Bt1FI4+xQBaC1rV0A56r +jaQQei8WEQ8RlUaZg0hsUuaklyrbNLaeD0kzesOnOpohZOg7rRS2z7XbwcgCeASFRAxOGtx/Lbb4 +uQx+HkThd0wcyAqtBqZ/5YB57tSwXQwMH+hyJbQEyA6pNkikNBHD2jeP7LyQEq6uZTT2GrpURxRq +TfQHW5tCc7K4nHPX3fvp9EvrgUBic2ih691QXjKaafLAVD/2PhJfgGmllRCiUbYGhKUScukS4VAm +i3yDTktl3Osw4K4BA6cKFzQI4iW3XNRHZZN0KtWdwyDKsZ5PKKXKEBWk3A82Wh3G8qfXp95rmd4c +fFkGzJY358LkohjKkEPzfqAk2lslqEsqsLApCXcniH4ijUDU1WxlUL9STU9ICapskuyl6f7KxIU9 +TDXSIDnbCYzLJAD/vzO7ixKYRgZPAPz8Z46m+mZVji4uvlPdQP2WLsYHTP8S3ELHgn9Hcq1LuJD2 +T69D+O4c2Pkl/N7GOr1h+6LtY0mxg5Dk/x0i1CqXqo4HtLqUYk6jDNDQsrZu+s9j0dJBMjQ2UFFK +fD5iWbOHSLZyf8csDq/NvGUURD2SfyQEAMoSEgKbV7PCkEIVkiQ7xuxLYuPqgN12pyLdJEy+b+kC +k1FSDXhIwco+Rr2wpWoLttboG+xhS86ssUEXPY5H05pM2W7DP/grPHSlwjxoNAL3JBvX+1Ky5HsT +/sK7REnR9BBbc1HgpDqchM9DuPwL1pRfGRgjYsegit0QxoYC0aZ+KKLo4PcmMkwtVOUn1YGIuiaM +C1fykVxKOqKqmpuFIoRYosQ7lqbHQi5pGtbNtGMTDfMKZKIYUaSAoYcM9TS7Kvon7qqkR/XHsE7E +fefPGtIg27iS0dbyTxJafGkqEJZHqG8vdeTvPXj0IEW1c8hcoRLnFZN46NCfIiLSMImfAQehx7yz +Y1caWbetnekYEw7BodfmqfrN5cH2M3XhDfn07Mk1IIg2NMLh8ronuPzzImk8WKDRWYx8fW6Ojbg2 +4UCTN2HIMlmhwMEt1eY5VecSzYG6LCRQCoSShnjdfBoP8gPr9YEkiDi+MaApYVe1tygISVlvZ/Lk +EZyUqlAZZObegOGhnprNaj/oEv9zX1+blt596AAb8kKMj1iCTGg1pl2ufS3eD3iJaDsgSPYCi3hu +yKRP4XLZwQg/+Xgb0YE+/8BHC3QJ6dfpYBFJI6PFMANdk5qYlvCZX+s4EVhnZAuLBf+I7W1DoYqt +luBPQHwevWCYFVuVBVeolZMei2/T9hk3fbN/YsqBiwZGuA/M6e981yOFRh+4dn//IrQs1CQK0Zuv +YuxjXBLJAMt7lCh5mwBZCK+Ic0C9FhAFtcYIwyHKqgC5Kdr5VbYXfJX5IcVZnj4yhS3fBCAhmiwu +OGQGSBR1GCk3iK8ll+yIGJL9yy4oHh5CVUxQk0KkMTFfF5xUeArIccxNDzIpw8j9tFdYiAnnWHqs +QZJj0k1nptY8Q29W4DllDklEOU+2E00yZGhs37/rRsmT/CAKQqpS4KWSm8ZgC9vAIaMy5gVnT4OP +faFaHcWclBirF8r8CZIWCpfdmkOcL/FMaf2jr16oX6JQDyYor4KlgWGWhMXloZiyLE+uqHmdnh1h +D62IiwHoIfyZBHbIBtJOi+k+n0CHk1uj4qdlTsJFuBSy9eiXCn4oj+rUREmlrOoEoy4eaPN8BTjn +UkQRp6qChCi/w1r6mMRhz9U+A1L6PkuQmxdHYX4VVXdz4bebcJz5lYUvVzLwGRp4O4kq5achi6Vf +vgNEC5fSj8SJyGMSY9PKsbOvipWdMEmL9cmPSOpZK9GiRUT7OYg+pBySUo/3rkTmkEZ3tcxOnlAF +TMZwiw+MnKrQw7acqWA8qTx8v20U71PUI+4sCoYGXrk9Bq+iwzPVAzpP56c7/LQX5RgSnt7yOQFL +njZCGiQVwkPuDiNaBlhBIGKVW9X6yRRegkMWuf361ZM/sHUuk8i5jKc8RdCx4lT8fTG3SnKkPfTy +r17LZyjO+7QVwgDXJMOhEy59m+q0QrlTGV28Dnru1EODjg5NekIXQ/KaiEDI/zfsRu9ttNP0N5Ec +qXh+oKH/yi/CA5HBN6EnyGzt5/Tdql/Q4T2SMvRJ/j9UqANQMmbvzYnqcZUq8LKKR/a4UHCEt5Qh +yxg6tntREaUZo2JUwsBfiYa4Fh1WcqpRoA/W7qkjDYMA56AjQQy47q2z5xgV+gKVtRDaj2pF31j1 +l+hFIRRvOdaWalJl0NHj1o7hjSdkP2nypRwOo8aeK80lgMT5gPDENGJmigkpJ3pYL3iwQcqO1+j+ +xI0/6/8AQHzbfg+aITyhXCkeywM9j/YEBUdQmWwbPsoW8hGd/2kiOiwOMdJYMBhJ810QUmU0E7IH +KrB8UNcd9v/tTyQh9rL2fOwQr65C4hIZoQDxTWSJoSHEjSgcFYhLZPbQp4wNK1GWaAeEKGSD4Cud +otL4O11wNTJHMDS2MdHHkkqAZI2s6NfCJNxDqNBYus0/QHgnJMYpP7pXunDBQO3135umBWpWkEze +F64CYt3RMjn3KaV4O0yQmovZsPQvdeiFJXmX4DFCuWIrpo+KTTMV++8XnJyN2TAYLcll5pphj/Gi +T1AqKWpvZAmh/QM/kt6SpJGARPaUwkvSi8ZZFzI4aVCCJzmzF47CtLdsCYskaXMSRefwJeu0QwDq +6s7wy5OLCsS7B0PEYfoURAL4XMVknhg182GhJMohSYCgE1dRWWZGbgWdsx5oLCBZ9Ep53oft7DlE +cg5WucX1N0sEYcIQHLAr8h8UjHvC2q5raYEKOBPV0V3H1zAQDCCpD6b70Lx9EYJ+pxkkieyT1Jtg +ByOQAaQMur2IKDJM3IYh+wtrc1z2zRYINReGMy3HoUlGSnXbK9apoiK7EpBR5Un1OIkfVGhKirGg +hypC5hiW0P9BaDTKmitoqKCqTBo5G08fmBHveah6PqA8v2tbuSrIzXQrnT5+MiHr1EdHm4zCkino +mTyKphR+piSmBM1sg67YL0ugDCExwr9SyM8LQ5hufLOT2N41fQYCFFUtztExpXJjsKAmiboYY0+y ++mFOfy3NGhXDoatKB0eOPhyvJ5HVQFAlvq4QVCcMPEKmh0oNI4B+2FVFNGfmAOcOpk8MiiHwypwE +7zT1pJZOmECqzU96SjGcX16Egt+q6kvuk85HxxSOFBATSG1Dl4AFWGZidlcTtkBvWoTqJJ+sfUcz +FzCVpi6WN4sbUZOCAKJ8MO8dZAh3obLrZFAagn6dK4i6pFWcFJtbBij4JtARWqJQmfesqEd3xvjK +mtSwlukX+FJPoQIcLEkbHhuFxG7EyG8bbqUp8U5OzCXDR86Sc1C+/snJwrc4jmXDX1/Bdlih5H6w +ibOLdCg0q1pZ08KCl6SBzsysorg/ORFIDhJbr0LfEP0kcm6KliKcaAVqN2luViobcj+BngzXyuQS +00CGfjd+obZmxiMN75qiWdCgEoellZWbieGktUpPAQzism5uh+BhA30yv3Z1aV4YAt538L0+E9Vs +zp3ke2QEM4GvInUaMy8jp6zJV/RiSvTC207DWoWkIaKFAj/EY5EPillB8d4/S6V+VoXrsko09EBC +rsMHVCGxIJRQg74hN3vqx6aSTEzMV6R2uc1Ry0K6VZUq/dZnNLFuJKA35HYlL60SioeDwszokPpE +IJMqyS6IZsi8cSgo9aj17p15S2BRpqiuc9epz6in9/48FI4mi2QnSiP130Bol016KgsN1Bc4ppkX +98MIl8FMxkoSlK+lrH+qEy+aXKXi11QBvqZSoVpCf8CCCyMWy6ypXF4yZ63q6NcqCrwLO3r26QwF +KoO+kjV04uQQwIyKQJhsbQeU0qvPWFv4hMfhzUj7OQrcY8Ui12RaoK/0zEp9Ijb8LhKR/ds4/R8I +oiarFFMZY0GklelSqqkm6TGB9Ej1qgr5AyJdp7rWqEaZ8FUXo2LcUvl4kRPibKo95S8R4vD7UprU +MzhniUqUDKmk5TQ9HTGcR1fCRA1POOwEZFQa9IgSU0FO0fSo6wTthy3BXQQhk4ZcZYfxQ7kcDjlB +LIIfol8pakn9hHORMgNCEnayEOpe0OFPSE1r+aQEO/2XfLw9+4+ixFFV0LqJvR6opFGNWf0lEqlc +VzHmcerRUSXKzTngVLYiZUU3VzDY8y8lVXFdN59g2XZOi6mEpqIJyxVVxgu8Q8t0z+PRmZx/UDcB +uBsJqIr6YIW7NhK2reYFir5dpzw86BHOoXG+JJGRUUeC0zfwXRFJAcPLugV6wPIeExpYJYMC+cVl +aKsynxUkNuVH9gRBs0+oSGeA6MqAewNOmX1wrBwLzMCRo3S/Eask2lRUizlB0TCb0g3LEPUTiJGs +z24Bb5tqhgq9Z1OiJUtYxwtvt9CCY0cP5xaKQ3rajSXTFDGvRs9rhBcUoldaYJcVdAFyFDcOdqzy +DMS66E405xmyYCfeYg3CcTIzKlQQUAl7PIQDK1OehsuJ/Cq41OVTOIJxl6sEKugRc0nLKHaXlIS8 +ekYJzXRDRIsoS6W83BsBlQplkRjedzzsRE4+uLTnIoRXiF9oKuIG7ecnLXhl8jnMskXKzRgf1Qr9 +OqdXLcuX9Mjtw+rYJQKRlHXayFXjtDkWg8oSBNOTpGIZwr9fmph/RWNx9UqYhK+egvW69RJr2xcp +BYemETV1t5MPd/Enku77tG+oGNQmxVy1NqpvafH/QkYO5QCkEytEeSyElo4YEq16IY3yYmXDaEZp +cAyhrHfIUZ2GwyAAsH1zuEu/8qpAJEJROUVZggNgI1lQQUN71c/ccrAugGbeoDr2cqt+0cWhbyya +EjwY2R6rOyWKhMo1cZR8NUEush7QVT7l4GCg4sVUlkkkl4XYUlT9qxt58yEiy7iIxY0/pA57RHZU +OF7iAiAAQySO67P0U5unZjEU4Y6aTXtYRaYosT4WAMM9NFJY7gDAQQS6ykNzMXD6U/FT+txQdH+D ++M8Qz8TVVF4Wq3BqJ21CQDd+MNTrJwfL4ZTiKGG4s+OIJad25PJQCos5AqzAVXu2or8hKZH+yPSv +R/K3GgZlF9hGNf/AUkzHx7LKEEdifYbyVonlzgaBwn4VJLNxrCsY9REHWh1cGQNebjTfnwMLEf2R +XoNmhj8wf/JmhaArWdHPX2wATY+verv2j4l+8Lm4hfLKWF68BCa66/2h0TSjM6sMccElaOicb6Cr +MRlXbQ5Dp/QwuvYQWN7oMiKXXVanXwBZ2GWVxr81lZWorO1yRUyt6WQmGP+yWl+7MG+z9/Qxc5vP +2z+bmfua/oZ/QnUhm/BbMIrUzG9WnK4ntzfSFmOPeF497RoX5ixiP3KCbkgfhCPLLl9/kuepNvTz +YGDDnj/ofIFRQDj5wK5x9sbFzmQnh0YSPlBbabD345Vgdc3+YwC3ks7/iOjLPh3GgkpK3wc3FWNr +NRr1w/KTNeMBAEXNELwyFu2A35M9VdQWIuQxsCsJGxFXQDbwEVFc0gJeSO0AR2gRAILOJDtCB0FN +VxgchhesYPEM2tVgKQJbXpVt/omgVmu4Bn5uKEHzlNGXaUh748Rm0kItthLQtuKBg5sAim8lNJVt +YI/pb6CY2uZw/IxoxroLtisoOxHr8ZVLhZvYRCHHUg6wiAQr/Nq+IvK+Z/6tSbOL7hX8B+g8+za3 +jf2GWeV41bnltneBMmfiR5DsB3YZCEq8F4jsG1BVAlwOBgz/DnAq9pYNtPapN55tkvRT+wwNf+// +marNrsPEnK2rxi4NVDknsCvqsgmKeNm/8rqu8l/5vB+nnArRNrpPHNRXHi5ygpf6JDziEHT1z0tq +ODv0qdyXjOwwjTP6ZY6bsaUOkev/P53o8h+oVO0ZiLFhokh05Bk4ffzJJz8u+ev4M1Kp7DoDhcu9 +GXUprCaH3YCe/1qbnEzc9v4LvrGh+pvGxu+RXXUzQWfTtIR5f/XhdsxM0vUij5oaM1Nn1pRTkkH1 +TczUI15eIW7JlHDR6FOjznTTBCUaLm16eoYf7Xt5MyFoUW8yH5B8tlheGRL3dVXeDCpxZtXZBYoE +WYw797TKhRRF+ttKJRc38MsthToENPEHVx3wQoWMLrUewxWTei4W2SGCScUpZ1sPsUkLqm6Mj0RM +t3/v44AeBTcQMcuJmXNdMmcu5Z7A2K/Z7sDlo02IzgLb7TsgqA5MWWygANwFRNQFO4d61wZz18Ff +8+pmDDp4jAIwhRjCBIIrd8P6Z5f7JnvztTbQv9UjWjjdYRG6k8qb5qu+elkUkvbeodk8PKURtENK +O8ClSJmTval69evBXL840k2l1qEdsAx790Wv9SpDTj/SzVxUvOiX0WJtX2tSRR4wSTl6TxsA0/Pv +NdG9bcR6G9fEgLYE/XihcjJlAyK1rwZdWu5RIOFi+fOJaQ9LZU42jTBQB06Js4vdR2FnBWoc8KfL +ZqNAz5tlTU8ue94yxOxnmdGzy5wLOQqmWcaxcVlHf703y5IeXDYOLlSaZfb9YjcjD6pJX/DxBGCU +oFiTsNbw8dbUPGLPQ14WeMkhX7ewb4p8nARIyKs28cyXN7kepwSEvOqUPQ9vMm6cbDzk/dZIVx1v +KkKcbBzydlwktjfJRJyKQcjLx5ucEaccQ17Fc6820tc5FPe6v0xinEmqhrxKuuSiD/eYbElOl4Qy +5NXqJOh2uSXrXcp/yKtE24BTcUugdSl+h7wpSylHP17u94W8KpBjg9yStS7lL+RVIlrdH6+CuZjo +3nSX9Ap5F8lYoAb1ZD0/3kTzzWI//T/26xeKW38ekrA6Lg5WNJUE5qXuXLyIYBi3d8WuTuiR6gcz +8JGCLeVBCla0mLnC02y4HJOnxia6lj3532dZuSab1kLwVaJhNp4XJRLa3Y5KSeUpwbR1sBvQgvmN ++VfAswJzJHdNU+atAP7lr1hKbNvEPnus1lS4td2GHp8AMZ+E8RGwbZrh11FN3vBTNRVKlSDru3qX +TAWNvSxvlux3i/nHkvZ4Z8nwI32YY0nSt17LhVnSOmy/Syy5TMIsybr23DWWPCY/S6ZbvJWGcc0s +if2xZA6zpLYv3v5QYrpL5sXFtRAHyDGJejGZv9AxqdWOEBaTTjkck9faxNyLSb12j0l2biL9Gz4B +OSavXyXfisldHo5JNjsSmwjo/1EKpjpN/4pU6T4wF1RaC6n+vxcZuheLoD5AsDZoSnK83LIftlOC +j/PHG9OFNqFQVzImFCazrAo7nXvuyq7wYHJvoKjClW/Icl33bciLJ1DSinXlIxIV5sVy7ArL/pk1 +qvugZOqnEhbhuyFdz4OWDRR6dBo5ivqsnNmCsveBtfXGrUxM2befbMMwrbngzN9LYl1UVStyBgSb +1Y8MkI3oskENsibRYX+iLnI3WUWJGg59xWMSx0F6/1iXH/t/Q/NWKmn+U0JCBTQLi9+lda/hxgrE +SB35w2EWNbWF/BhD+r8NQGCnqxzJVmM55l/dHfrpdyyfJkEU02+wte2dYJkchsciElisWO8VAu55 +hbFPdtJbcfzsVSQGF2LkWwKHL+nxEXEM9/TyJLwRi8w8lQ9C6JCCDkonkDiByMnnQZolYquoyV05 +aQMCJK7kwHpU1bEYEq3V0Zgfx8QqBOUSFEiGsghyLdjOpSH0LM/Fz5gaPG4+pxbUqvQvJnAvgoOw +LXMNMscjAPmxDg1CoMHBEcLJawhEEAaGHTqATikE7JoyYW6ETQJEUfcNMDjEKZyD/C5+PVybNr2h +XVEt7zGxqLgLxW3BVBRL+txgNLPNYzFIIYQisQ2h8xQHDl/xdpylEp1jVgEZKiEJrC4cR/FkJKXR +hsXyMr7WcP0PRMHldrS5yTdbwz38PtjRIEnpsoYTpAwxyiBBWVNrUDvsC27klOlAyZcurYVlvLz9 +UOkciYPmUi9coCH2eccFcqnLBE80kkQlXyZILjgW1VbL2Dxve5L0Mx9aqam/SiyR9+qVpM2dZRO4 +/O56KUpRYHLLq83KISxKqaBXLrsJq1NQav7s1aMxr6JZ7zHNOKx1XA6xiqVcT0xSnCiOOKzOFj/A +M9OobmfJJJcPbElNDA4bGIkyLv3odZCW3ED6FobYK0pmHy4RDu03 + + + + diff --git a/internal/cmd/stats/heartbit.svg b/internal/cmd/stats/heartbit.svg new file mode 100644 index 0000000000000000000000000000000000000000..daef4b0b57f392d02699d5e7371671633ffd57f2 --- /dev/null +++ b/internal/cmd/stats/heartbit.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/cmd/stats/index.css b/internal/cmd/stats/index.css new file mode 100644 index 0000000000000000000000000000000000000000..b01c84442f6cbe1675f46ec02a65d801d0abed2d --- /dev/null +++ b/internal/cmd/stats/index.css @@ -0,0 +1,275 @@ +:root { + /* Dark mode colors - charmtone dark palette */ + --bg: #201f26; + --bg-secondary: #2d2c35; + --text: #fffaf1; + --text-muted: #858392; + + /* Charmtone colors (global - same in both light and dark modes) */ + --charple: #6b50ff; + --cherry: #ff388b; + --julep: #00ffb2; + --urchin: #c337e0; + --butter: #fffaf1; + --squid: #858392; + --pepper: #201f26; + --iron: #4d4c57; + --tuna: #ff6daa; + --uni: #ff937d; + --coral: #ff577d; + --violet: #c259ff; + --malibu: #00a4ff; + --hazy: #8b75ff; +} + +/* Light mode colors - charmtone light palette */ +@media (prefers-color-scheme: light) { + :root { + --bg: #f0f0f0; + --bg-secondary: #fbfbfb; + --text: #201f26; + --text-muted: #4d4c57; + } +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, + sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + padding: 2rem 1rem; +} + +.container { + max-width: 1200px; + margin: 0 auto; +} + +.header-wrapper { + max-width: 1200px; + margin: 0 auto 2rem; +} + +.header-wrapper a { + display: block; + text-decoration: none; +} + +.header-content { + display: flex; + align-items: center; + width: 100%; +} + +.header-svg { + flex-grow: 1; + flex-shrink: 1; + min-width: 0; + overflow: hidden; + height: 70px; + display: flex; + align-items: center; +} + +.header-svg svg { + height: 70px; + width: auto; + min-width: 1300px; + display: block; + pointer-events: none; +} + +.heartbit-svg { + flex-shrink: 0; + width: 70px; + flex-basis: 70px; + margin-left: 1rem; +} + +.heartbit-svg svg { + width: 100%; + height: auto; + display: block; +} + +.header-info { + margin-bottom: 2rem; + font-size: 0.875rem; + color: var(--hazy); + font-family: "JetBrains Mono", "SF Mono", Consolas, monospace; +} + +.stats-grid { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 2rem; + width: 100%; +} + +.stat-card { + background: var(--bg-secondary); + border-radius: 12px; + padding: 1.5rem; + flex: 1 1 150px; + max-width: calc((100% - 5rem) / 6); +} + +@media (prefers-color-scheme: light) { + .stat-card { + background: var(--butter); + } +} + +@media (max-width: 1024px) { + .stat-card { + max-width: calc((100% - 2rem) / 3); + } +} + +@media (max-width: 600px) { + .stat-card { + max-width: calc((100% - 1rem) / 2); + } +} + +.stat-card h3 { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.5rem; +} + +.stat-card .value { + font-size: 2rem; + font-weight: 700; + color: var(--butter); + white-space: nowrap; +} + +@media (prefers-color-scheme: light) { + .stat-card .value { + color: var(--pepper); + } +} + +.charts-grid { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-bottom: 2rem; + width: 100%; +} + +.chart-card { + background: var(--bg-secondary); + border-radius: 12px; + padding: 1.5rem; + width: 100%; + box-sizing: border-box; +} + +@media (prefers-color-scheme: light) { + .chart-card { + background: var(--butter); + } +} + +.chart-card.full-width { + width: 100%; +} + +.chart-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; + width: 100%; +} + +.chart-row .chart-card { + width: 100%; +} + +@media (max-width: 1024px) { + .chart-row { + grid-template-columns: 1fr; + } +} + +.chart-card h2 { + font-size: 1.25rem; + margin-bottom: 1rem; + color: var(--text); +} + +.chart-container { + position: relative; + height: 300px; +} + +.chart-container.tall { + height: 400px; +} + +table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; +} + +th, +td { + text-align: left; + padding: 0.75rem; + border-bottom: 1px solid var(--border); +} + +th { + color: var(--text-muted); + font-weight: 500; + font-size: 0.875rem; +} + +td { + font-family: "JetBrains Mono", "SF Mono", Consolas, monospace; +} + +.model-tag { + background: var(--bg); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; +} + +.footer-container { + max-width: 1200px; + margin: 2rem auto 0; +} + +.footer-container svg { + width: 100%; + height: auto; + display: block; +} + +/* Override charm brand colors in footer */ +.footer-container .st2 { + fill: #fffaf1 !important; +} + +@media (prefers-color-scheme: light) { + + /* Override charm brand colors in footer */ + .footer-container .st2 { + fill: #644ced !important; + } +} diff --git a/internal/cmd/stats/index.html b/internal/cmd/stats/index.html new file mode 100644 index 0000000000000000000000000000000000000000..4b25831f86c76f86bf405d3c9e77ddb2b7d1821e --- /dev/null +++ b/internal/cmd/stats/index.html @@ -0,0 +1,136 @@ + + + + + + Crush Usage Statistics + + + + + + + + +
+ + +
+ Generated by {{.Username}} for {{.ProjectName}} in {{.GeneratedAt}}. +
+ +
+
+

Total Sessions

+
+
+
+

Total Messages

+
+
+
+

Total Tokens

+
+
+
+

Total Cost

+
+
+
+

Tokens/Session

+
+
+
+

Response Time

+
+
+
+ +
+
+

Activity Heatmap

+
+ +
+
+ +
+

Activity (Last 30 Days)

+
+ +
+
+ +
+

Tool Usage

+
+ +
+
+ +
+
+

Messages by Provider

+
+ +
+
+ +
+

Token Distribution

+
+ +
+
+
+ +
+

Usage by Model

+
+ +
+
+ +
+

Daily Usage History

+
+ + + + + + + + + + + + +
DateSessionsPrompt TokensCompletion TokensTotal TokensCost
+
+
+
+
+ + + + + + diff --git a/internal/cmd/stats/index.js b/internal/cmd/stats/index.js new file mode 100644 index 0000000000000000000000000000000000000000..3007322881e458312a31702f735c1c3257bcfb6d --- /dev/null +++ b/internal/cmd/stats/index.js @@ -0,0 +1,356 @@ +// Get all charmtone colors once from computed styles +const rootStyles = getComputedStyle(document.documentElement); +const colors = { + charple: rootStyles.getPropertyValue("--charple").trim(), + cherry: rootStyles.getPropertyValue("--cherry").trim(), + julep: rootStyles.getPropertyValue("--julep").trim(), + urchin: rootStyles.getPropertyValue("--urchin").trim(), + butter: rootStyles.getPropertyValue("--butter").trim(), + squid: rootStyles.getPropertyValue("--squid").trim(), + pepper: rootStyles.getPropertyValue("--pepper").trim(), + tuna: rootStyles.getPropertyValue("--tuna").trim(), + uni: rootStyles.getPropertyValue("--uni").trim(), + coral: rootStyles.getPropertyValue("--coral").trim(), + violet: rootStyles.getPropertyValue("--violet").trim(), + malibu: rootStyles.getPropertyValue("--malibu").trim(), +}; + +const easeDuration = 500; +const easeType = "easeOutQuart"; + +// Helper functions +function formatNumber(n) { + return new Intl.NumberFormat().format(Math.round(n)); +} + +function formatCompact(n) { + if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"; + if (n >= 1000) return (n / 1000).toFixed(1) + "k"; + return Math.round(n).toString(); +} + +function formatCost(n) { + return "$" + n.toFixed(2); +} + +function formatTime(ms) { + if (ms < 1000) return Math.round(ms) + "ms"; + return (ms / 1000).toFixed(1) + "s"; +} + +const charpleColor = { r: 107, g: 80, b: 255 }; +const tunaColor = { r: 255, g: 109, b: 170 }; + +function interpolateColor(ratio, alpha = 1) { + const r = Math.round(charpleColor.r + (tunaColor.r - charpleColor.r) * ratio); + const g = Math.round(charpleColor.g + (tunaColor.g - charpleColor.g) * ratio); + const b = Math.round(charpleColor.b + (tunaColor.b - charpleColor.b) * ratio); + if (alpha < 1) { + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + return `rgb(${r}, ${g}, ${b})`; +} + +function getTopItemsWithOthers(items, countKey, labelKey, topN = 10) { + const topItems = items.slice(0, topN); + const otherItems = items.slice(topN); + const otherCount = otherItems.reduce((sum, item) => sum + item[countKey], 0); + const displayItems = [...topItems]; + if (otherItems.length > 0) { + const otherItem = { [countKey]: otherCount, [labelKey]: "others" }; + displayItems.push(otherItem); + } + return displayItems; +} + +// Populate summary cards +document.getElementById("total-sessions").textContent = formatNumber( + stats.total.total_sessions, +); +document.getElementById("total-messages").textContent = formatCompact( + stats.total.total_messages, +); +document.getElementById("total-tokens").textContent = formatCompact( + stats.total.total_tokens, +); +document.getElementById("total-cost").textContent = formatCost( + stats.total.total_cost, +); +document.getElementById("avg-tokens").innerHTML = + ' ' + + formatCompact(stats.total.avg_tokens_per_session); +document.getElementById("avg-response").innerHTML = + ' ' + formatTime(stats.avg_response_time_ms); + +// Chart defaults +Chart.defaults.color = colors.squid; +Chart.defaults.borderColor = colors.squid; + +if (stats.recent_activity?.length > 0) { + new Chart(document.getElementById("recentActivityChart"), { + type: "bar", + data: { + labels: stats.recent_activity.map((d) => d.day), + datasets: [ + { + label: "Sessions", + data: stats.recent_activity.map((d) => d.session_count), + backgroundColor: colors.charple, + borderRadius: 4, + yAxisID: "y", + }, + { + label: "Tokens (K)", + data: stats.recent_activity.map((d) => d.total_tokens / 1000), + backgroundColor: colors.julep, + borderRadius: 4, + yAxisID: "y1", + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 800, easing: easeType }, + interaction: { mode: "index", intersect: false }, + scales: { + y: { position: "left", title: { display: true, text: "Sessions" } }, + y1: { + position: "right", + title: { display: true, text: "Tokens (K)" }, + grid: { drawOnChartArea: false }, + }, + }, + }, + }); +} + +// Heatmap (Hour × Day of Week) - Bubble Chart +const dayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +let maxCount = + stats.hour_day_heatmap?.length > 0 + ? Math.max(...stats.hour_day_heatmap.map((h) => h.session_count)) + : 0; +if (maxCount === 0) maxCount = 1; +const scaleFactor = 20 / Math.sqrt(maxCount); + +if (stats.hour_day_heatmap?.length > 0) { + new Chart(document.getElementById("heatmapChart"), { + type: "bubble", + data: { + datasets: [ + { + label: "Sessions", + data: stats.hour_day_heatmap + .filter((h) => h.session_count > 0) + .map((h) => ({ + x: h.hour, + y: h.day_of_week, + r: Math.sqrt(h.session_count) * scaleFactor, + count: h.session_count, + })), + backgroundColor: (ctx) => { + const count = + ctx.raw?.count || ctx.dataset.data[ctx.dataIndex]?.count || 0; + const ratio = count / maxCount; + return interpolateColor(ratio); + }, + borderWidth: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + scales: { + x: { + min: 0, + max: 23, + grid: { display: false }, + title: { display: true, text: "Hour of Day" }, + ticks: { + stepSize: 1, + callback: (v) => (Number.isInteger(v) ? v : ""), + }, + }, + y: { + min: 0, + max: 6, + reverse: true, + grid: { display: false }, + title: { display: true, text: "Day of Week" }, + ticks: { stepSize: 1, callback: (v) => dayLabels[v] || "" }, + }, + }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label: (ctx) => + dayLabels[ctx.raw.y] + + " " + + ctx.raw.x + + ":00 - " + + ctx.raw.count + + " sessions", + }, + }, + }, + }, + }); +} + +if (stats.tool_usage?.length > 0) { + const displayTools = getTopItemsWithOthers( + stats.tool_usage, + "call_count", + "tool_name", + ); + const maxValue = Math.max(...displayTools.map((t) => t.call_count)); + new Chart(document.getElementById("toolChart"), { + type: "bar", + data: { + labels: displayTools.map((t) => t.tool_name), + datasets: [ + { + label: "Calls", + data: displayTools.map((t) => t.call_count), + backgroundColor: (ctx) => { + const value = ctx.raw; + const ratio = value / maxValue; + return interpolateColor(ratio); + }, + borderRadius: 4, + }, + ], + }, + options: { + indexAxis: "y", + responsive: true, + maintainAspectRatio: false, + animation: { duration: easeDuration, easing: easeType }, + plugins: { legend: { display: false } }, + }, + }); +} + +// Token Distribution Pie +new Chart(document.getElementById("tokenPieChart"), { + type: "doughnut", + data: { + labels: ["Prompt Tokens", "Completion Tokens"], + datasets: [ + { + data: [ + stats.total.total_prompt_tokens, + stats.total.total_completion_tokens, + ], + backgroundColor: [colors.charple, colors.julep], + borderWidth: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { duration: easeDuration, easing: easeType }, + plugins: { + legend: { position: "bottom" }, + }, + }, +}); + +// Model Usage Chart (horizontal bar) +if (stats.usage_by_model?.length > 0) { + const displayModels = getTopItemsWithOthers( + stats.usage_by_model, + "message_count", + "model", + ); + const maxModelValue = Math.max(...displayModels.map((m) => m.message_count)); + new Chart(document.getElementById("modelChart"), { + type: "bar", + data: { + labels: displayModels.map((m) => + m.provider ? `${m.model} (${m.provider})` : m.model, + ), + datasets: [ + { + label: "Messages", + data: displayModels.map((m) => m.message_count), + backgroundColor: (ctx) => { + const value = ctx.raw; + const ratio = value / maxModelValue; + return interpolateColor(ratio); + }, + borderRadius: 4, + }, + ], + }, + options: { + indexAxis: "y", + responsive: true, + maintainAspectRatio: false, + animation: { duration: easeDuration, easing: easeType }, + plugins: { legend: { display: false } }, + }, + }); +} + +if (stats.usage_by_model?.length > 0) { + const providerData = stats.usage_by_model.reduce((acc, m) => { + acc[m.provider] = (acc[m.provider] || 0) + m.message_count; + return acc; + }, {}); + const providerColors = [ + colors.malibu, + colors.charple, + colors.violet, + colors.tuna, + colors.coral, + colors.uni, + ]; + new Chart(document.getElementById("providerPieChart"), { + type: "doughnut", + data: { + labels: Object.keys(providerData), + datasets: [ + { + data: Object.values(providerData), + backgroundColor: Object.keys(providerData).map( + (_, i) => providerColors[i % providerColors.length], + ), + borderWidth: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { duration: easeDuration, easing: easeType }, + plugins: { + legend: { position: "bottom" }, + }, + }, + }); +} + +// Daily Usage Table +const tableBody = document.querySelector("#daily-table tbody"); +if (stats.usage_by_day?.length > 0) { + const fragment = document.createDocumentFragment(); + stats.usage_by_day.slice(0, 30).forEach((d) => { + const row = document.createElement("tr"); + row.innerHTML = `${d.day}${d.session_count}${formatNumber( + d.prompt_tokens, + )}${formatNumber( + d.completion_tokens, + )}${formatNumber(d.total_tokens)}${formatCost( + d.cost, + )}`; + fragment.appendChild(row); + }); + tableBody.appendChild(fragment); +} diff --git a/internal/db/db.go b/internal/db/db.go index 7fa2e6528743dcb5485c0de9b4a3f2b46eb39376..81c3179e22f6768b2ffa2c5b4af2e10c385d5835 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -48,18 +48,45 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteSessionMessagesStmt, err = db.PrepareContext(ctx, deleteSessionMessages); err != nil { return nil, fmt.Errorf("error preparing query DeleteSessionMessages: %w", err) } + if q.getAverageResponseTimeStmt, err = db.PrepareContext(ctx, getAverageResponseTime); err != nil { + return nil, fmt.Errorf("error preparing query GetAverageResponseTime: %w", err) + } if q.getFileStmt, err = db.PrepareContext(ctx, getFile); err != nil { return nil, fmt.Errorf("error preparing query GetFile: %w", err) } if q.getFileByPathAndSessionStmt, err = db.PrepareContext(ctx, getFileByPathAndSession); err != nil { return nil, fmt.Errorf("error preparing query GetFileByPathAndSession: %w", err) } + if q.getHourDayHeatmapStmt, err = db.PrepareContext(ctx, getHourDayHeatmap); err != nil { + return nil, fmt.Errorf("error preparing query GetHourDayHeatmap: %w", err) + } if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil { return nil, fmt.Errorf("error preparing query GetMessage: %w", err) } + if q.getRecentActivityStmt, err = db.PrepareContext(ctx, getRecentActivity); err != nil { + return nil, fmt.Errorf("error preparing query GetRecentActivity: %w", err) + } if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil { return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err) } + if q.getToolUsageStmt, err = db.PrepareContext(ctx, getToolUsage); err != nil { + return nil, fmt.Errorf("error preparing query GetToolUsage: %w", err) + } + if q.getTotalStatsStmt, err = db.PrepareContext(ctx, getTotalStats); err != nil { + return nil, fmt.Errorf("error preparing query GetTotalStats: %w", err) + } + if q.getUsageByDayStmt, err = db.PrepareContext(ctx, getUsageByDay); err != nil { + return nil, fmt.Errorf("error preparing query GetUsageByDay: %w", err) + } + if q.getUsageByDayOfWeekStmt, err = db.PrepareContext(ctx, getUsageByDayOfWeek); err != nil { + return nil, fmt.Errorf("error preparing query GetUsageByDayOfWeek: %w", err) + } + if q.getUsageByHourStmt, err = db.PrepareContext(ctx, getUsageByHour); err != nil { + return nil, fmt.Errorf("error preparing query GetUsageByHour: %w", err) + } + if q.getUsageByModelStmt, err = db.PrepareContext(ctx, getUsageByModel); err != nil { + return nil, fmt.Errorf("error preparing query GetUsageByModel: %w", err) + } if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil { return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err) } @@ -132,6 +159,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteSessionMessagesStmt: %w", cerr) } } + if q.getAverageResponseTimeStmt != nil { + if cerr := q.getAverageResponseTimeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAverageResponseTimeStmt: %w", cerr) + } + } if q.getFileStmt != nil { if cerr := q.getFileStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getFileStmt: %w", cerr) @@ -142,16 +174,56 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getFileByPathAndSessionStmt: %w", cerr) } } + if q.getHourDayHeatmapStmt != nil { + if cerr := q.getHourDayHeatmapStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getHourDayHeatmapStmt: %w", cerr) + } + } if q.getMessageStmt != nil { if cerr := q.getMessageStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getMessageStmt: %w", cerr) } } + if q.getRecentActivityStmt != nil { + if cerr := q.getRecentActivityStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getRecentActivityStmt: %w", cerr) + } + } if q.getSessionByIDStmt != nil { if cerr := q.getSessionByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr) } } + if q.getToolUsageStmt != nil { + if cerr := q.getToolUsageStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getToolUsageStmt: %w", cerr) + } + } + if q.getTotalStatsStmt != nil { + if cerr := q.getTotalStatsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getTotalStatsStmt: %w", cerr) + } + } + if q.getUsageByDayStmt != nil { + if cerr := q.getUsageByDayStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUsageByDayStmt: %w", cerr) + } + } + if q.getUsageByDayOfWeekStmt != nil { + if cerr := q.getUsageByDayOfWeekStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUsageByDayOfWeekStmt: %w", cerr) + } + } + if q.getUsageByHourStmt != nil { + if cerr := q.getUsageByHourStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUsageByHourStmt: %w", cerr) + } + } + if q.getUsageByModelStmt != nil { + if cerr := q.getUsageByModelStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUsageByModelStmt: %w", cerr) + } + } if q.listFilesByPathStmt != nil { if cerr := q.listFilesByPathStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr) @@ -244,10 +316,19 @@ type Queries struct { deleteSessionStmt *sql.Stmt deleteSessionFilesStmt *sql.Stmt deleteSessionMessagesStmt *sql.Stmt + getAverageResponseTimeStmt *sql.Stmt getFileStmt *sql.Stmt getFileByPathAndSessionStmt *sql.Stmt + getHourDayHeatmapStmt *sql.Stmt getMessageStmt *sql.Stmt + getRecentActivityStmt *sql.Stmt getSessionByIDStmt *sql.Stmt + getToolUsageStmt *sql.Stmt + getTotalStatsStmt *sql.Stmt + getUsageByDayStmt *sql.Stmt + getUsageByDayOfWeekStmt *sql.Stmt + getUsageByHourStmt *sql.Stmt + getUsageByModelStmt *sql.Stmt listFilesByPathStmt *sql.Stmt listFilesBySessionStmt *sql.Stmt listLatestSessionFilesStmt *sql.Stmt @@ -271,10 +352,19 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { deleteSessionStmt: q.deleteSessionStmt, deleteSessionFilesStmt: q.deleteSessionFilesStmt, deleteSessionMessagesStmt: q.deleteSessionMessagesStmt, + getAverageResponseTimeStmt: q.getAverageResponseTimeStmt, getFileStmt: q.getFileStmt, getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt, + getHourDayHeatmapStmt: q.getHourDayHeatmapStmt, getMessageStmt: q.getMessageStmt, + getRecentActivityStmt: q.getRecentActivityStmt, getSessionByIDStmt: q.getSessionByIDStmt, + getToolUsageStmt: q.getToolUsageStmt, + getTotalStatsStmt: q.getTotalStatsStmt, + getUsageByDayStmt: q.getUsageByDayStmt, + getUsageByDayOfWeekStmt: q.getUsageByDayOfWeekStmt, + getUsageByHourStmt: q.getUsageByHourStmt, + getUsageByModelStmt: q.getUsageByModelStmt, listFilesByPathStmt: q.listFilesByPathStmt, listFilesBySessionStmt: q.listFilesBySessionStmt, listLatestSessionFilesStmt: q.listLatestSessionFilesStmt, diff --git a/internal/db/querier.go b/internal/db/querier.go index dfa6d722535b4265f3f54331d1904523a648f562..c70386690c6c42aca53a2b6682ddca0f3a0262ba 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -17,10 +17,19 @@ type Querier interface { DeleteSession(ctx context.Context, id string) error DeleteSessionFiles(ctx context.Context, sessionID string) error DeleteSessionMessages(ctx context.Context, sessionID string) error + GetAverageResponseTime(ctx context.Context) (int64, error) GetFile(ctx context.Context, id string) (File, error) GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error) + GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error) GetMessage(ctx context.Context, id string) (Message, error) + GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error) GetSessionByID(ctx context.Context, id string) (Session, error) + GetToolUsage(ctx context.Context) ([]GetToolUsageRow, error) + GetTotalStats(ctx context.Context) (GetTotalStatsRow, error) + GetUsageByDay(ctx context.Context) ([]GetUsageByDayRow, error) + GetUsageByDayOfWeek(ctx context.Context) ([]GetUsageByDayOfWeekRow, error) + GetUsageByHour(ctx context.Context) ([]GetUsageByHourRow, error) + GetUsageByModel(ctx context.Context) ([]GetUsageByModelRow, error) ListFilesByPath(ctx context.Context, path string) ([]File, error) ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) diff --git a/internal/db/sql/stats.sql b/internal/db/sql/stats.sql new file mode 100644 index 0000000000000000000000000000000000000000..02f2c33425b299870827b2d05f458106b82b599c --- /dev/null +++ b/internal/db/sql/stats.sql @@ -0,0 +1,93 @@ +-- name: GetUsageByDay :many +SELECT + date(created_at, 'unixepoch') as day, + SUM(prompt_tokens) as prompt_tokens, + SUM(completion_tokens) as completion_tokens, + SUM(cost) as cost, + COUNT(*) as session_count +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY date(created_at, 'unixepoch') +ORDER BY day DESC; + +-- name: GetUsageByModel :many +SELECT + COALESCE(model, 'unknown') as model, + COALESCE(provider, 'unknown') as provider, + COUNT(*) as message_count +FROM messages +WHERE role = 'assistant' +GROUP BY model, provider +ORDER BY message_count DESC; + +-- name: GetUsageByHour :many +SELECT + CAST(strftime('%H', created_at, 'unixepoch') AS INTEGER) as hour, + COUNT(*) as session_count +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY hour +ORDER BY hour; + +-- name: GetUsageByDayOfWeek :many +SELECT + CAST(strftime('%w', created_at, 'unixepoch') AS INTEGER) as day_of_week, + COUNT(*) as session_count, + SUM(prompt_tokens) as prompt_tokens, + SUM(completion_tokens) as completion_tokens +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY day_of_week +ORDER BY day_of_week; + +-- name: GetTotalStats :one +SELECT + COUNT(*) as total_sessions, + COALESCE(SUM(prompt_tokens), 0) as total_prompt_tokens, + COALESCE(SUM(completion_tokens), 0) as total_completion_tokens, + COALESCE(SUM(cost), 0) as total_cost, + COALESCE(SUM(message_count), 0) as total_messages, + COALESCE(AVG(prompt_tokens + completion_tokens), 0) as avg_tokens_per_session, + COALESCE(AVG(message_count), 0) as avg_messages_per_session +FROM sessions +WHERE parent_session_id IS NULL; + +-- name: GetRecentActivity :many +SELECT + date(created_at, 'unixepoch') as day, + COUNT(*) as session_count, + SUM(prompt_tokens + completion_tokens) as total_tokens, + SUM(cost) as cost +FROM sessions +WHERE parent_session_id IS NULL + AND created_at >= strftime('%s', 'now', '-30 days') +GROUP BY date(created_at, 'unixepoch') +ORDER BY day ASC; + +-- name: GetAverageResponseTime :one +SELECT + CAST(COALESCE(AVG(finished_at - created_at), 0) AS INTEGER) as avg_response_seconds +FROM messages +WHERE role = 'assistant' + AND finished_at IS NOT NULL + AND finished_at > created_at; + +-- name: GetToolUsage :many +SELECT + json_extract(value, '$.data.name') as tool_name, + COUNT(*) as call_count +FROM messages, json_each(parts) +WHERE json_extract(value, '$.type') = 'tool_call' + AND json_extract(value, '$.data.name') IS NOT NULL +GROUP BY tool_name +ORDER BY call_count DESC; + +-- name: GetHourDayHeatmap :many +SELECT + CAST(strftime('%w', created_at, 'unixepoch') AS INTEGER) as day_of_week, + CAST(strftime('%H', created_at, 'unixepoch') AS INTEGER) as hour, + COUNT(*) as session_count +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY day_of_week, hour +ORDER BY day_of_week, hour; diff --git a/internal/db/stats.sql.go b/internal/db/stats.sql.go new file mode 100644 index 0000000000000000000000000000000000000000..119dd410a07c7e47a2f7369c0ee2fdca8c19b7c3 --- /dev/null +++ b/internal/db/stats.sql.go @@ -0,0 +1,367 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: stats.sql + +package db + +import ( + "context" + "database/sql" +) + +const getAverageResponseTime = `-- name: GetAverageResponseTime :one +SELECT + CAST(COALESCE(AVG(finished_at - created_at), 0) AS INTEGER) as avg_response_seconds +FROM messages +WHERE role = 'assistant' + AND finished_at IS NOT NULL + AND finished_at > created_at +` + +func (q *Queries) GetAverageResponseTime(ctx context.Context) (int64, error) { + row := q.queryRow(ctx, q.getAverageResponseTimeStmt, getAverageResponseTime) + var avg_response_seconds int64 + err := row.Scan(&avg_response_seconds) + return avg_response_seconds, err +} + +const getHourDayHeatmap = `-- name: GetHourDayHeatmap :many +SELECT + CAST(strftime('%w', created_at, 'unixepoch') AS INTEGER) as day_of_week, + CAST(strftime('%H', created_at, 'unixepoch') AS INTEGER) as hour, + COUNT(*) as session_count +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY day_of_week, hour +ORDER BY day_of_week, hour +` + +type GetHourDayHeatmapRow struct { + DayOfWeek int64 `json:"day_of_week"` + Hour int64 `json:"hour"` + SessionCount int64 `json:"session_count"` +} + +func (q *Queries) GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error) { + rows, err := q.query(ctx, q.getHourDayHeatmapStmt, getHourDayHeatmap) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetHourDayHeatmapRow{} + for rows.Next() { + var i GetHourDayHeatmapRow + if err := rows.Scan(&i.DayOfWeek, &i.Hour, &i.SessionCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getRecentActivity = `-- name: GetRecentActivity :many +SELECT + date(created_at, 'unixepoch') as day, + COUNT(*) as session_count, + SUM(prompt_tokens + completion_tokens) as total_tokens, + SUM(cost) as cost +FROM sessions +WHERE parent_session_id IS NULL + AND created_at >= strftime('%s', 'now', '-30 days') +GROUP BY date(created_at, 'unixepoch') +ORDER BY day ASC +` + +type GetRecentActivityRow struct { + Day interface{} `json:"day"` + SessionCount int64 `json:"session_count"` + TotalTokens sql.NullFloat64 `json:"total_tokens"` + Cost sql.NullFloat64 `json:"cost"` +} + +func (q *Queries) GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error) { + rows, err := q.query(ctx, q.getRecentActivityStmt, getRecentActivity) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetRecentActivityRow{} + for rows.Next() { + var i GetRecentActivityRow + if err := rows.Scan( + &i.Day, + &i.SessionCount, + &i.TotalTokens, + &i.Cost, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getToolUsage = `-- name: GetToolUsage :many +SELECT + json_extract(value, '$.data.name') as tool_name, + COUNT(*) as call_count +FROM messages, json_each(parts) +WHERE json_extract(value, '$.type') = 'tool_call' + AND json_extract(value, '$.data.name') IS NOT NULL +GROUP BY tool_name +ORDER BY call_count DESC +` + +type GetToolUsageRow struct { + ToolName interface{} `json:"tool_name"` + CallCount int64 `json:"call_count"` +} + +func (q *Queries) GetToolUsage(ctx context.Context) ([]GetToolUsageRow, error) { + rows, err := q.query(ctx, q.getToolUsageStmt, getToolUsage) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetToolUsageRow{} + for rows.Next() { + var i GetToolUsageRow + if err := rows.Scan(&i.ToolName, &i.CallCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getTotalStats = `-- name: GetTotalStats :one +SELECT + COUNT(*) as total_sessions, + COALESCE(SUM(prompt_tokens), 0) as total_prompt_tokens, + COALESCE(SUM(completion_tokens), 0) as total_completion_tokens, + COALESCE(SUM(cost), 0) as total_cost, + COALESCE(SUM(message_count), 0) as total_messages, + COALESCE(AVG(prompt_tokens + completion_tokens), 0) as avg_tokens_per_session, + COALESCE(AVG(message_count), 0) as avg_messages_per_session +FROM sessions +WHERE parent_session_id IS NULL +` + +type GetTotalStatsRow struct { + TotalSessions int64 `json:"total_sessions"` + TotalPromptTokens interface{} `json:"total_prompt_tokens"` + TotalCompletionTokens interface{} `json:"total_completion_tokens"` + TotalCost interface{} `json:"total_cost"` + TotalMessages interface{} `json:"total_messages"` + AvgTokensPerSession interface{} `json:"avg_tokens_per_session"` + AvgMessagesPerSession interface{} `json:"avg_messages_per_session"` +} + +func (q *Queries) GetTotalStats(ctx context.Context) (GetTotalStatsRow, error) { + row := q.queryRow(ctx, q.getTotalStatsStmt, getTotalStats) + var i GetTotalStatsRow + err := row.Scan( + &i.TotalSessions, + &i.TotalPromptTokens, + &i.TotalCompletionTokens, + &i.TotalCost, + &i.TotalMessages, + &i.AvgTokensPerSession, + &i.AvgMessagesPerSession, + ) + return i, err +} + +const getUsageByDay = `-- name: GetUsageByDay :many +SELECT + date(created_at, 'unixepoch') as day, + SUM(prompt_tokens) as prompt_tokens, + SUM(completion_tokens) as completion_tokens, + SUM(cost) as cost, + COUNT(*) as session_count +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY date(created_at, 'unixepoch') +ORDER BY day DESC +` + +type GetUsageByDayRow struct { + Day interface{} `json:"day"` + PromptTokens sql.NullFloat64 `json:"prompt_tokens"` + CompletionTokens sql.NullFloat64 `json:"completion_tokens"` + Cost sql.NullFloat64 `json:"cost"` + SessionCount int64 `json:"session_count"` +} + +func (q *Queries) GetUsageByDay(ctx context.Context) ([]GetUsageByDayRow, error) { + rows, err := q.query(ctx, q.getUsageByDayStmt, getUsageByDay) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUsageByDayRow{} + for rows.Next() { + var i GetUsageByDayRow + if err := rows.Scan( + &i.Day, + &i.PromptTokens, + &i.CompletionTokens, + &i.Cost, + &i.SessionCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUsageByDayOfWeek = `-- name: GetUsageByDayOfWeek :many +SELECT + CAST(strftime('%w', created_at, 'unixepoch') AS INTEGER) as day_of_week, + COUNT(*) as session_count, + SUM(prompt_tokens) as prompt_tokens, + SUM(completion_tokens) as completion_tokens +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY day_of_week +ORDER BY day_of_week +` + +type GetUsageByDayOfWeekRow struct { + DayOfWeek int64 `json:"day_of_week"` + SessionCount int64 `json:"session_count"` + PromptTokens sql.NullFloat64 `json:"prompt_tokens"` + CompletionTokens sql.NullFloat64 `json:"completion_tokens"` +} + +func (q *Queries) GetUsageByDayOfWeek(ctx context.Context) ([]GetUsageByDayOfWeekRow, error) { + rows, err := q.query(ctx, q.getUsageByDayOfWeekStmt, getUsageByDayOfWeek) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUsageByDayOfWeekRow{} + for rows.Next() { + var i GetUsageByDayOfWeekRow + if err := rows.Scan( + &i.DayOfWeek, + &i.SessionCount, + &i.PromptTokens, + &i.CompletionTokens, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUsageByHour = `-- name: GetUsageByHour :many +SELECT + CAST(strftime('%H', created_at, 'unixepoch') AS INTEGER) as hour, + COUNT(*) as session_count +FROM sessions +WHERE parent_session_id IS NULL +GROUP BY hour +ORDER BY hour +` + +type GetUsageByHourRow struct { + Hour int64 `json:"hour"` + SessionCount int64 `json:"session_count"` +} + +func (q *Queries) GetUsageByHour(ctx context.Context) ([]GetUsageByHourRow, error) { + rows, err := q.query(ctx, q.getUsageByHourStmt, getUsageByHour) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUsageByHourRow{} + for rows.Next() { + var i GetUsageByHourRow + if err := rows.Scan(&i.Hour, &i.SessionCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUsageByModel = `-- name: GetUsageByModel :many +SELECT + COALESCE(model, 'unknown') as model, + COALESCE(provider, 'unknown') as provider, + COUNT(*) as message_count +FROM messages +WHERE role = 'assistant' +GROUP BY model, provider +ORDER BY message_count DESC +` + +type GetUsageByModelRow struct { + Model string `json:"model"` + Provider string `json:"provider"` + MessageCount int64 `json:"message_count"` +} + +func (q *Queries) GetUsageByModel(ctx context.Context) ([]GetUsageByModelRow, error) { + rows, err := q.query(ctx, q.getUsageByModelStmt, getUsageByModel) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUsageByModelRow{} + for rows.Next() { + var i GetUsageByModelRow + if err := rows.Scan(&i.Model, &i.Provider, &i.MessageCount); 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 +} From 7154e46becc542a08a56d6149de754fc5873a7c1 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 26 Jan 2026 16:19:11 +0100 Subject: [PATCH 228/335] feat: update session title (#1988) * feat: update session title * chore: implement review requests --- internal/ui/dialog/sessions.go | 147 ++++++++++++++++++++++++++-- internal/ui/dialog/sessions_item.go | 52 ++++++++-- internal/ui/styles/styles.go | 18 ++++ 3 files changed, 203 insertions(+), 14 deletions(-) diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 2dadc209bced543077a143d03bbc16f6bdf1524d..4f607ab0e23d43b58eac7784abc3fed658d4bcba 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -2,11 +2,13 @@ package dialog import ( "context" + "strings" "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/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" @@ -43,6 +45,9 @@ type Session struct { Previous key.Binding UpDown key.Binding Delete key.Binding + Rename key.Binding + ConfirmRename key.Binding + CancelRename key.Binding ConfirmDelete key.Binding CancelDelete key.Binding Close key.Binding @@ -103,6 +108,18 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) key.WithKeys("ctrl+x"), key.WithHelp("ctrl+x", "delete"), ) + s.keyMap.Rename = key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r", "rename"), + ) + s.keyMap.ConfirmRename = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ) + s.keyMap.CancelRename = key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ) s.keyMap.ConfirmDelete = key.NewBinding( key.WithKeys("y"), key.WithHelp("y", "delete"), @@ -129,15 +146,40 @@ func (s *Session) HandleMsg(msg tea.Msg) Action { case sessionsModeDeleting: switch { case key.Matches(msg, s.keyMap.ConfirmDelete): - return s.confirmDeleteSession() + action := s.confirmDeleteSession() + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...) + s.list.SelectFirst() + s.list.ScrollToSelected() + return action case key.Matches(msg, s.keyMap.CancelDelete): s.sessionsMode = sessionsModeNormal s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...) } + case sessionsModeUpdating: + switch { + case key.Matches(msg, s.keyMap.ConfirmRename): + action := s.confirmRenameSession() + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...) + return action + case key.Matches(msg, s.keyMap.CancelRename): + s.sessionsMode = sessionsModeNormal + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...) + default: + item := s.list.SelectedItem() + if item == nil { + return nil + } + if sessionItem, ok := item.(*SessionItem); ok { + return sessionItem.HandleInput(msg) + } + } default: switch { case key.Matches(msg, s.keyMap.Close): return ActionClose{} + case key.Matches(msg, s.keyMap.Rename): + s.sessionsMode = sessionsModeUpdating + 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...")} @@ -218,6 +260,51 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { rc.TitleGradientToColor = t.Dialog.Sessions.DeletingTitleGradientToColor rc.ViewStyle = t.Dialog.Sessions.DeletingView rc.AddPart(t.Dialog.Sessions.DeletingMessage.Render("Delete this session?")) + case sessionsModeUpdating: + rc.TitleStyle = t.Dialog.Sessions.UpdatingTitle + rc.TitleGradientFromColor = t.Dialog.Sessions.UpdatingTitleGradientFromColor + rc.TitleGradientToColor = t.Dialog.Sessions.UpdatingTitleGradientToColor + rc.ViewStyle = t.Dialog.Sessions.UpdatingView + message := t.Dialog.Sessions.UpdatingMessage.Render("Rename this session?") + rc.AddPart(message) + item := s.selectedSessionItem() + if item == nil { + return nil + } + cur = item.Cursor() + if cur == nil { + break + } + + start, end := s.list.VisibleItemIndices() + selectedIndex := s.list.Selected() + + titleStyle := t.Dialog.Sessions.UpdatingTitle + dialogStyle := t.Dialog.Sessions.UpdatingView + inputStyle := t.Dialog.InputPrompt + + // Adjust cursor position to account for dialog layout + message + cur.X += inputStyle.GetBorderLeftSize() + + inputStyle.GetMarginLeft() + + inputStyle.GetPaddingLeft() + + dialogStyle.GetBorderLeftSize() + + dialogStyle.GetPaddingLeft() + + dialogStyle.GetMarginLeft() + cur.Y += titleStyle.GetVerticalFrameSize() + + inputStyle.GetBorderTopSize() + + inputStyle.GetMarginTop() + + inputStyle.GetPaddingTop() + + inputStyle.GetBorderBottomSize() + + inputStyle.GetMarginBottom() + + inputStyle.GetPaddingBottom() + + dialogStyle.GetPaddingTop() + + dialogStyle.GetBorderTopSize() + + lipgloss.Height(message) - 1 + + // move the cursor by one down until we see the selectedIndex + for ; start <= end && start != selectedIndex && selectedIndex > -1; start++ { + cur.Y += 1 + } default: inputView := t.Dialog.InputPrompt.Render(s.input.View()) cur = s.Cursor() @@ -260,9 +347,6 @@ func (s *Session) removeSession(id string) { newSessions = append(newSessions, sess) } s.sessions = newSessions - s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...) - s.list.SelectFirst() - s.list.ScrollToSelected() } func (s *Session) deleteSessionCmd(id string) tea.Cmd { @@ -275,6 +359,42 @@ func (s *Session) deleteSessionCmd(id string) tea.Cmd { } } +func (s *Session) confirmRenameSession() Action { + sessionItem := s.selectedSessionItem() + s.sessionsMode = sessionsModeNormal + if sessionItem == nil { + return nil + } + + newTitle := strings.TrimSpace(sessionItem.InputValue()) + if newTitle == "" { + return nil + } + session := sessionItem.Session + session.Title = newTitle + s.updateSession(session) + return ActionCmd{s.updateSessionCmd(session)} +} + +func (s *Session) updateSession(session session.Session) { + for existingID, sess := range s.sessions { + if sess.ID == session.ID { + s.sessions[existingID] = session + break + } + } +} + +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 nil + } +} + func (s *Session) isCurrentSessionBusy() bool { sessionItem := s.selectedSessionItem() if sessionItem == nil { @@ -296,11 +416,17 @@ func (s *Session) ShortHelp() []key.Binding { s.keyMap.ConfirmDelete, s.keyMap.CancelDelete, } + case sessionsModeUpdating: + return []key.Binding{ + s.keyMap.ConfirmRename, + s.keyMap.CancelRename, + } default: return []key.Binding{ s.keyMap.UpDown, - s.keyMap.Select, + s.keyMap.Rename, s.keyMap.Delete, + s.keyMap.Select, s.keyMap.Close, } } @@ -310,10 +436,10 @@ func (s *Session) ShortHelp() []key.Binding { func (s *Session) FullHelp() [][]key.Binding { m := [][]key.Binding{} slice := []key.Binding{ - s.keyMap.Select, - s.keyMap.Next, - s.keyMap.Previous, + s.keyMap.UpDown, + s.keyMap.Rename, s.keyMap.Delete, + s.keyMap.Select, s.keyMap.Close, } @@ -323,6 +449,11 @@ func (s *Session) FullHelp() [][]key.Binding { s.keyMap.ConfirmDelete, s.keyMap.CancelDelete, } + case sessionsModeUpdating: + slice = []key.Binding{ + s.keyMap.ConfirmRename, + s.keyMap.CancelRename, + } } for i := 0; i < len(slice); i += 4 { end := min(i+4, len(slice)) diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index 47ffd4878727c10a4be89ed373402dd214573a14..87a2627daa3b63eca309feeb914ec80c33e2ef1f 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -5,6 +5,8 @@ import ( "strings" "time" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/list" @@ -28,11 +30,12 @@ type ListItem interface { // SessionItem wraps a [session.Session] to implement the [ListItem] interface. type SessionItem struct { session.Session - t *styles.Styles - sessionsMode sessionsMode - m fuzzy.Match - cache map[int]string - focused bool + t *styles.Styles + sessionsMode sessionsMode + m fuzzy.Match + cache map[int]string + updateTitleInput textinput.Model + focused bool } var _ ListItem = &SessionItem{} @@ -53,6 +56,23 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) { s.m = m } +// InputValue returns the updated title value +func (s *SessionItem) InputValue() string { + return s.updateTitleInput.Value() +} + +// HandleInput forwards input message to the update title input +func (s *SessionItem) HandleInput(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + s.updateTitleInput, cmd = s.updateTitleInput.Update(msg) + return cmd +} + +// Cursor returns the cursor of the update title input +func (s *SessionItem) Cursor() *tea.Cursor { + return s.updateTitleInput.Cursor() +} + // Render returns the string representation of the session item. func (s *SessionItem) Render(width int) string { info := humanize.Time(time.Unix(s.UpdatedAt, 0)) @@ -67,7 +87,17 @@ func (s *SessionItem) Render(width int) string { case sessionsModeDeleting: styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused + case sessionsModeUpdating: + styles.ItemBlurred = s.t.Dialog.Sessions.UpdatingItemBlurred + styles.ItemFocused = s.t.Dialog.Sessions.UpdatingItemFocused + if s.focused { + inputWidth := width - styles.InfoTextFocused.GetHorizontalFrameSize() + s.updateTitleInput.SetWidth(inputWidth) + s.updateTitleInput.Placeholder = ansi.Truncate(s.Title, width, "…") + return styles.ItemFocused.Render(s.updateTitleInput.View()) + } } + return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m) } @@ -157,7 +187,17 @@ func (s *SessionItem) SetFocused(focused bool) { func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem { items := make([]list.FilterableItem, len(sessions)) for i, s := range sessions { - items[i] = &SessionItem{Session: s, t: t, sessionsMode: mode} + item := &SessionItem{Session: s, t: t, sessionsMode: mode} + if mode == sessionsModeUpdating { + item.updateTitleInput = textinput.New() + item.updateTitleInput.SetVirtualCursor(false) + item.updateTitleInput.Prompt = "" + inputStyle := t.TextInput + inputStyle.Focused.Placeholder = inputStyle.Focused.Placeholder.Foreground(t.FgHalfMuted) + item.updateTitleInput.SetStyles(inputStyle) + item.updateTitleInput.Focus() + } + items[i] = item } return items } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index cce2471267bb179bf26cee2b29c870c0998584fb..455658e7f4900196f7c03dcc1564ea734f780a64 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -369,6 +369,7 @@ type Styles struct { ImagePreview lipgloss.Style Sessions struct { + // styles for when we are in delete mode DeletingView lipgloss.Style DeletingItemFocused lipgloss.Style DeletingItemBlurred lipgloss.Style @@ -376,6 +377,15 @@ type Styles struct { DeletingMessage lipgloss.Style DeletingTitleGradientFromColor color.Color DeletingTitleGradientToColor color.Color + + // styles for when we are in update mode + UpdatingView lipgloss.Style + UpdatingItemFocused lipgloss.Style + UpdatingItemBlurred lipgloss.Style + UpdatingTitle lipgloss.Style + UpdatingMessage lipgloss.Style + UpdatingTitleGradientFromColor color.Color + UpdatingTitleGradientToColor color.Color } } @@ -1287,6 +1297,14 @@ func DefaultStyles() Styles { s.Dialog.Sessions.DeletingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle) s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red) + s.Dialog.Sessions.UpdatingTitle = s.Dialog.Title.Foreground(charmtone.Zest) + s.Dialog.Sessions.UpdatingView = s.Dialog.View.BorderForeground(charmtone.Zest) + s.Dialog.Sessions.UpdatingMessage = s.Base.Padding(1) + s.Dialog.Sessions.UpdatingTitleGradientFromColor = charmtone.Zest + s.Dialog.Sessions.UpdatingTitleGradientToColor = charmtone.Bok + s.Dialog.Sessions.UpdatingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle) + s.Dialog.Sessions.UpdatingItemFocused = s.Dialog.SelectedItem.UnsetBackground().UnsetForeground() + s.Status.Help = lipgloss.NewStyle().Padding(0, 1) s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!") s.Status.InfoIndicator = s.Status.SuccessIndicator From 9faa8b227fc13c741ea89751024765bd8a2437a1 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 26 Jan 2026 16:19:25 +0100 Subject: [PATCH 229/335] fix: enable left/right scrolling of diff (#1984) * fix: enable left/rigth scrolling of diff this also fixes an issue introduced in #1931 where the model info was not showing in the landing page. * chore: simplify model info --- internal/ui/dialog/permissions.go | 43 ++++++++++++++++++++++++++----- internal/ui/model/sidebar.go | 3 +-- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/internal/ui/dialog/permissions.go b/internal/ui/dialog/permissions.go index 143dbbd8baffa8e89b1654175039e8eb9d913bf0..d877d7085afbe8920c96898ce029e059dfa59e46 100644 --- a/internal/ui/dialog/permissions.go +++ b/internal/ui/dialog/permissions.go @@ -70,6 +70,7 @@ type Permissions struct { // Diff view state. diffSplitMode *bool // nil means use default based on width defaultDiffSplitMode bool // default split mode based on width + diffXOffset int // horizontal scroll offset for diff view unifiedDiffContent string splitDiffContent string @@ -259,12 +260,31 @@ func (p *Permissions) HandleMsg(msg tea.Msg) Action { case key.Matches(msg, p.keyMap.ScrollUp): p.viewport, _ = p.viewport.Update(msg) case key.Matches(msg, p.keyMap.ScrollLeft): - p.viewport, _ = p.viewport.Update(msg) + if p.hasDiffView() { + p.scrollLeft() + } else { + p.viewport, _ = p.viewport.Update(msg) + } case key.Matches(msg, p.keyMap.ScrollRight): - p.viewport, _ = p.viewport.Update(msg) + if p.hasDiffView() { + p.scrollRight() + } else { + p.viewport, _ = p.viewport.Update(msg) + } } case tea.MouseWheelMsg: - p.viewport, _ = p.viewport.Update(msg) + if p.hasDiffView() { + switch msg.Button { + case tea.MouseWheelLeft: + p.scrollLeft() + case tea.MouseWheelRight: + p.scrollRight() + default: + p.viewport, _ = p.viewport.Update(msg) + } + } else { + p.viewport, _ = p.viewport.Update(msg) + } default: // Pass unhandled keys to viewport for non-diff content scrolling. if !p.hasDiffView() { @@ -309,6 +329,18 @@ func (p *Permissions) isSplitMode() bool { return p.defaultDiffSplitMode } +const horizontalScrollStep = 5 + +func (p *Permissions) scrollLeft() { + p.diffXOffset = max(0, p.diffXOffset-horizontalScrollStep) + p.viewportDirty = true +} + +func (p *Permissions) scrollRight() { + p.diffXOffset += horizontalScrollStep + p.viewportDirty = true +} + // Draw implements [Dialog]. func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := p.com.Styles @@ -558,10 +590,7 @@ func (p *Permissions) renderDiff(filePath, oldContent, newContent string, conten formatter := common.DiffFormatter(p.com.Styles). Before(fsext.PrettyPath(filePath), oldContent). After(fsext.PrettyPath(filePath), newContent). - // TODO: Allow horizontal scrolling instead of cropping. However, the - // diffview currently would only background color the width of the - // content. If the viewport is wider than the content, the rest of the - // line would not be colored properly. + XOffset(p.diffXOffset). Width(contentWidth) var result string diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index a0623a1262863da749bbaebdfb2f42eccef7cf50..7e6a61864a42f37ba7bf1c955b6844f4c488b942 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -51,9 +51,8 @@ func (m *UI) modelInfo(width int) string { Cost: m.session.Cost, ModelContext: model.CatwalkCfg.ContextWindow, } - return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width) } - return "" + return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width) } // getDynamicHeightLimits will give us the num of items to show in each section based on the hight From dd3834ab37670dd8be2be0805d2b3d47780d20ec Mon Sep 17 00:00:00 2001 From: Amolith Date: Mon, 26 Jan 2026 08:30:37 -0700 Subject: [PATCH 230/335] fix(agent): read step data for summarization check (#1787) --- internal/agent/agent.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c916cfd886372ab86f6d1fbb0e8b7bde2c87dabb..15aa1c0a840e8aafd2feced6bd4bb580e17be5f8 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -379,6 +379,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy } a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata)) _, sessionErr := a.sessions.Save(genCtx, updatedSession) + if sessionErr == nil { + currentSession = updatedSession + } sessionLock.Unlock() if sessionErr != nil { return sessionErr From f7914468a9e05dd1ce3a7d8b8bd674b401817969 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 26 Jan 2026 12:58:56 -0300 Subject: [PATCH 231/335] fix: stats chart don't account for cached tokens Signed-off-by: Carlos Alexandro Becker --- internal/agent/agent.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 15aa1c0a840e8aafd2feced6bd4bb580e17be5f8..d5b0ed5f1cf4333922ef65c6e0a0fafcf92710a2 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -858,7 +858,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user } promptTokens := resp.TotalUsage.InputTokens + resp.TotalUsage.CacheCreationTokens - completionTokens := resp.TotalUsage.OutputTokens + resp.TotalUsage.CacheReadTokens + completionTokens := resp.TotalUsage.OutputTokens // Atomically update only title and usage fields to avoid overriding other // concurrent session updates. @@ -897,7 +897,7 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, session.Cost += cost } - session.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens + session.CompletionTokens = usage.OutputTokens session.PromptTokens = usage.InputTokens + usage.CacheCreationTokens } From b490a3b761ee946d49727622de3246bc3f83dbe9 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 26 Jan 2026 14:54:11 -0300 Subject: [PATCH 232/335] chore(deps): update `github.com/charmbracelet/x/exp/strings` --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fd40f65510b9a35ea3b9b1b7e2f464eca9e50535..988ae03f17043bf8b0b3b1476812039211439685 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( 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/strings v0.0.0-20260119114936-fd556377ea59 + github.com/charmbracelet/x/exp/strings v0.1.0 github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b github.com/charmbracelet/x/term v0.2.2 github.com/denisbrodbeck/machineid v1.0.1 diff --git a/go.sum b/go.sum index cddd80bdbc09faed251039a667ade88c04136e03..5df4b8526457e28d88fe2c9c4b6d0f8072166cfd 100644 --- a/go.sum +++ b/go.sum @@ -118,8 +118,8 @@ github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sB 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/strings v0.0.0-20260119114936-fd556377ea59 h1:cvPMInXNmK/CHjQU8eXC/oSnGfEKpQmndsEykh03bt0= -github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= +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= github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM= github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8= From 99a896d10897177e7c67e7861ceb8dd47248a769 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 26 Jan 2026 14:54:41 -0300 Subject: [PATCH 233/335] refactor: use `ContainsAnyOf` from `x` --- internal/cmd/root.go | 4 ++-- internal/cmd/root_test.go | 4 ++-- internal/stringext/string.go | 11 ----------- internal/tui/tui.go | 4 ++-- 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index e7489777b5938b0edecba2b333643839974fede5..351c9d414dd28b596374cf3a99459a1098d3c41b 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/stringext" "github.com/charmbracelet/crush/internal/tui" "github.com/charmbracelet/crush/internal/ui/common" ui "github.com/charmbracelet/crush/internal/ui/model" @@ -29,6 +28,7 @@ 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" ) @@ -314,5 +314,5 @@ func shouldQueryCapabilities(env uv.Environ) bool { return (!okTermProg && !okSSHTTY) || (!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) || // Terminals that do support XTVERSION. - stringext.ContainsAny(termType, kittyTerminals...) + xstrings.ContainsAnyOf(termType, kittyTerminals...) } diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 2b6ca86c50dfeba036574242726c269e14617442..8b92f04c4ab7b120985505716e6200cd1845d295 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/charmbracelet/crush/internal/stringext" uv "github.com/charmbracelet/ultraviolet" + xstrings "github.com/charmbracelet/x/exp/strings" "github.com/stretchr/testify/require" ) @@ -153,7 +153,7 @@ func TestStringextContainsAny(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := stringext.ContainsAny(tt.s, tt.substr...) + got := xstrings.ContainsAnyOf(tt.s, tt.substr...) require.Equal(t, tt.want, got) }) } diff --git a/internal/stringext/string.go b/internal/stringext/string.go index 9383ce1d78b8f0a776fc533526ee961d0123d734..03456db93bc148f7c77e52da3c493c94fa79624f 100644 --- a/internal/stringext/string.go +++ b/internal/stringext/string.go @@ -1,8 +1,6 @@ package stringext import ( - "strings" - "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -10,12 +8,3 @@ import ( func Capitalize(text string) string { return cases.Title(language.English, cases.Compact).String(text) } - -func ContainsAny(str string, args ...string) bool { - for _, arg := range args { - if strings.Contains(str, arg) { - return true - } - } - return false -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 7586cd8a494541e52a3e403fe0374b772bd7d332..9a51a2497f09875d743e1051465dec7c1ac46e67 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -19,7 +19,6 @@ import ( "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/pubsub" - "github.com/charmbracelet/crush/internal/stringext" 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" @@ -37,6 +36,7 @@ import ( "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" @@ -129,7 +129,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } termVersion := strings.ToLower(msg.Name) switch { - case stringext.ContainsAny(termVersion, "ghostty", "rio"): + case xstrings.ContainsAnyOf(termVersion, "ghostty", "rio"): a.sendProgressBar = true case strings.Contains(termVersion, "iterm2"): // iTerm2 supports progress bars from version v3.6.6 From 5edc4cbf7a75aee80807f276fa0d05bf2fa5f647 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 26 Jan 2026 16:02:37 -0300 Subject: [PATCH 234/335] ci: use goreleaser nightly (#1987) * ci: use goreleaser nightly reason: https://github.com/goreleaser/goreleaser/commit/20d273bf4a96d2cebd0d99dcd9b10dc17cf71d61 should fix https://github.com/charmbracelet/nur/issues/42 Signed-off-by: Carlos Alexandro Becker * chore: comment Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7337e1dc58c039785a4b3f51a161cc3217d4a8a2..368a53af629553b1d34bc682f7f5e7b7f53be777 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,8 @@ jobs: with: go_version: "1.25" macos_sign_entitlements: "./.github/entitlements.plist" + # XXX: remove it after goreleaser 2.14. + goreleaser_version: nightly secrets: docker_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_token: ${{ secrets.DOCKERHUB_TOKEN }} From 53d065eba178f0e5b9adbe3123487c575636000a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:03:27 -0300 Subject: [PATCH 235/335] chore(deps): bump the all group with 5 updates (#1986) Bumps the all group with 5 updates: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `6.0.1` | `6.0.2` | | [actions/setup-go](https://github.com/actions/setup-go) | `6.1.0` | `6.2.0` | | [github/codeql-action](https://github.com/github/codeql-action) | `4.31.10` | `4.31.11` | | [anchore/scan-action](https://github.com/anchore/scan-action) | `7.2.3` | `7.3.0` | | [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) | `6.3.0` | `6.4.0` | Updates `actions/checkout` from 6.0.1 to 6.0.2 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/8e8c483db84b4bee98b60c0593521ed34d9990e8...de0fac2e4500dabe0009e67214ff5f5447ce83dd) Updates `actions/setup-go` from 6.1.0 to 6.2.0 - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v6.1.0...7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5) Updates `github/codeql-action` from 4.31.10 to 4.31.11 - [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/cdefb33c0f6224e58673d9004f47f7cb3e328b89...19b2f06db2b6f5108140aeb04014ef02b648f789) Updates `anchore/scan-action` from 7.2.3 to 7.3.0 - [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/62b74fb7bb810d2c45b1865f47a77655621862a5...0d444ed77d83ee2ba7f5ced0d90d640a1281d762) Updates `goreleaser/goreleaser-action` from 6.3.0 to 6.4.0 - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/9c156ee8a17a598857849441385a2041ef570552...e435ccd777264be153ace6237001ef4d979d3a7a) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: actions/setup-go dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: github/codeql-action dependency-version: 4.31.11 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: anchore/scan-action dependency-version: 7.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: goreleaser/goreleaser-action dependency-version: 6.4.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> --- .github/workflows/build.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/schema-update.yml | 2 +- .github/workflows/security.yml | 20 ++++++++++---------- .github/workflows/snapshot.yml | 6 +++--- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39e104129f0c3c059b116acabb0a86f5e53e6dc4..07481c3d96382c8f06af322bc5eb6c13e990d37a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,7 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index f24ccb5c189f5a591d652f3142683e7d94e2bd1e..2cd5823e2473abcbbd2e6adffbbed52118cb1251 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -11,7 +11,7 @@ jobs: outputs: should_run: ${{ steps.check.outputs.should_run }} steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - id: check diff --git a/.github/workflows/schema-update.yml b/.github/workflows/schema-update.yml index b02ad11ebd556490b6f2abbb4af172166a458d18..967c9b47af65a6f912e95c22784fbc93aae4f275 100644 --- a/.github/workflows/schema-update.yml +++ b/.github/workflows/schema-update.yml @@ -11,7 +11,7 @@ jobs: update-schema: runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b0e1a82cb63e020221a32abdf9534f610ec82b43..b90761035ecf5d9292c35b567c5b4a3d36efa1b9 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -27,14 +27,14 @@ jobs: pull-requests: read security-events: write steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 + - uses: github/codeql-action/init@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11 with: languages: ${{ matrix.language }} - - uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 - - uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 + - uses: github/codeql-action/autobuild@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11 + - uses: github/codeql-action/analyze@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11 grype: runs-on: ubuntu-latest @@ -43,16 +43,16 @@ jobs: actions: read contents: read steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3 + - uses: anchore/scan-action@0d444ed77d83ee2ba7f5ced0d90d640a1281d762 # v7.3.0 id: scan with: path: "." fail-build: true severity-cutoff: critical - - uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 + - uses: github/codeql-action/upload-sarif@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11 with: sarif_file: ${{ steps.scan.outputs.sarif }} @@ -62,7 +62,7 @@ jobs: security-events: write contents: read steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 @@ -73,7 +73,7 @@ jobs: - name: Run govulncheck run: | govulncheck -C . -format sarif ./... > results.sarif - - uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 + - uses: github/codeql-action/upload-sarif@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11 with: sarif_file: results.sarif @@ -83,7 +83,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index ca5e3a78241d0b9be901d19e398b86efeb1715d2..0c3d5ce6d437a39471003018545d8546fa220ef6 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -18,14 +18,14 @@ jobs: # Use our own large runners with more CPU and RAM for faster builds group: releasers steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version-file: go.mod - - uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 + - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 with: version: "~> v2" distribution: goreleaser-pro From 13c28e6e466a3e5c04820fffd67b2e202d8e8250 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:04:36 -0300 Subject: [PATCH 236/335] chore(deps): bump the all group with 6 updates (#1985) Bumps the all group with 6 updates: | Package | From | To | | --- | --- | --- | | [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma) | `2.23.0` | `2.23.1` | | [github.com/bmatcuk/doublestar/v4](https://github.com/bmatcuk/doublestar) | `4.9.2` | `4.10.0` | | [github.com/ncruces/go-sqlite3](https://github.com/ncruces/go-sqlite3) | `0.30.4` | `0.30.5` | | [github.com/posthog/posthog-go](https://github.com/posthog/posthog-go) | `1.9.0` | `1.9.1` | | [github.com/zeebo/xxh3](https://github.com/zeebo/xxh3) | `1.0.2` | `1.1.0` | | [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | `1.44.2` | `1.44.3` | Updates `github.com/alecthomas/chroma/v2` from 2.23.0 to 2.23.1 - [Release notes](https://github.com/alecthomas/chroma/releases) - [Commits](https://github.com/alecthomas/chroma/compare/v2.23.0...v2.23.1) Updates `github.com/bmatcuk/doublestar/v4` from 4.9.2 to 4.10.0 - [Release notes](https://github.com/bmatcuk/doublestar/releases) - [Commits](https://github.com/bmatcuk/doublestar/compare/v4.9.2...v4.10.0) Updates `github.com/ncruces/go-sqlite3` from 0.30.4 to 0.30.5 - [Release notes](https://github.com/ncruces/go-sqlite3/releases) - [Commits](https://github.com/ncruces/go-sqlite3/compare/v0.30.4...v0.30.5) Updates `github.com/posthog/posthog-go` from 1.9.0 to 1.9.1 - [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.0...v1.9.1) Updates `github.com/zeebo/xxh3` from 1.0.2 to 1.1.0 - [Commits](https://github.com/zeebo/xxh3/compare/v1.0.2...v1.1.0) Updates `modernc.org/sqlite` from 1.44.2 to 1.44.3 - [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md) - [Commits](https://gitlab.com/cznic/sqlite/compare/v1.44.2...v1.44.3) --- updated-dependencies: - dependency-name: github.com/alecthomas/chroma/v2 dependency-version: 2.23.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: github.com/bmatcuk/doublestar/v4 dependency-version: 4.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: github.com/ncruces/go-sqlite3 dependency-version: 0.30.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: github.com/posthog/posthog-go dependency-version: 1.9.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: github.com/zeebo/xxh3 dependency-version: 1.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: modernc.org/sqlite dependency-version: 1.44.3 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> --- go.mod | 14 +++++++------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 988ae03f17043bf8b0b3b1476812039211439685..30c5613bf400e4568bf0662b6c340d371b1d4268 100644 --- a/go.mod +++ b/go.mod @@ -13,11 +13,11 @@ require ( github.com/JohannesKaufmann/html-to-markdown v1.6.0 github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.11.0 - github.com/alecthomas/chroma/v2 v2.23.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.9.2 + github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charlievieth/fastwalk v1.0.14 github.com/charmbracelet/catwalk v0.15.0 github.com/charmbracelet/colorprofile v0.4.1 @@ -45,12 +45,12 @@ require ( 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.4 + 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 - github.com/posthog/posthog-go v1.9.0 + github.com/posthog/posthog-go v1.9.1 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 @@ -62,14 +62,14 @@ require ( 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.0.2 + 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 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.44.2 + modernc.org/sqlite v1.44.3 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 ) @@ -137,7 +137,7 @@ require ( github.com/kaptinlin/jsonschema v0.6.6 // indirect github.com/kaptinlin/messageformat-go v0.4.7 // indirect github.com/klauspost/compress v1.18.0 // indirect - github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect diff --git a/go.sum b/go.sum index 5df4b8526457e28d88fe2c9c4b6d0f8072166cfd..5f1787b0a9e5372580a3a92dfbb43e2786e582bb 100644 --- a/go.sum +++ b/go.sum @@ -39,8 +39,8 @@ github.com/RealAlexandreAI/json-repair v0.0.14 h1:4kTqotVonDVTio5n2yweRUELVcNe2x github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.23.0 h1:u/Orux1J0eLuZDeQ44froV8smumheieI0EofhbyKhhk= -github.com/alecthomas/chroma/v2 v2.23.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= @@ -88,8 +88,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= -github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI= -github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= @@ -234,8 +234,8 @@ github.com/kaptinlin/messageformat-go v0.4.7 h1:HQ/OvFUSU7+fAHWkZnP2ug9y+A/ZyTE8 github.com/kaptinlin/messageformat-go v0.4.7/go.mod h1:DusKpv8CIybczGvwIVn3j13hbR3psr5mOwhFudkiq1c= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -275,8 +275,8 @@ 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.4 h1:j9hEoOL7f9ZoXl8uqXVniaq1VNwlWAXihZbTvhqPPjA= -github.com/ncruces/go-sqlite3 v0.30.4/go.mod h1:7WR20VSC5IZusKhUdiR9y1NsUqnZgqIYCmKKoMEYg68= +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= @@ -298,8 +298,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.0 h1:7tRfnaHqPNrBNTnSnFLQwJ5aVz6LOBngiwl15lD8bHU= -github.com/posthog/posthog-go v1.9.0/go.mod h1:0i1H2BlsK9mHvHGc9Kp6oenUlHUqPl45hWzRtR/2PVI= +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/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= @@ -369,8 +369,8 @@ github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= @@ -540,8 +540,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.44.2 h1:EdYqXeBpKFJjg8QYnw6E71MpANkoxyuYi+g68ugOL8g= -modernc.org/sqlite v1.44.2/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= +modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= From 668adccfcae951f5523b33c91f03a6d4a4ca1650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BC=D1=98=D0=B0=D0=BD=20=D0=93=D0=B5=D0=BE?= =?UTF-8?q?=D1=80=D0=B3=D0=B8=D0=B5=D0=B2=D1=81=D0=BA=D0=B8?= Date: Mon, 26 Jan 2026 20:05:53 +0100 Subject: [PATCH 237/335] ci(goreleaser): fix aur_sorces build to properly set the version (#1978) it seems that the variable provided to -ldflags, at some point changed from `main.version` to `github.com/charmbracelet/crush/internal/version.Version`. so reflect that to the build script for the AUR package. expected change in the PKGBUILD is: ``` diff --git a/PKGBUILD b/PKGBUILD index e9afb74..58779bf 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -23,7 +23,7 @@ build() { export CGO_CXXFLAGS="${CXXFLAGS}" export CGO_LDFLAGS="${LDFLAGS}" export GOFLAGS="-buildmode=pie -trimpath -mod=readonly -modcacherw" - go build -ldflags="-w -s -buildid='' -linkmode=external -X main.version=v${pkgver}" . + go build -ldflags="-w -s -buildid='' -linkmode=external -X github.com/charmbracelet/crush/internal/version.Version=v${pkgver}" . ./crush completion bash >./completions/crush.bash ./crush completion zsh >./completions/crush.zsh ./crush completion fish >./completions/crush.fish ``` --- .goreleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index c79a0c9fd96f1d9bc76c9f09f60649f7fc6f7018..784201677ed863e460818d98ac54e651bbfb7fee 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -116,7 +116,7 @@ aur_sources: export CGO_CXXFLAGS="${CXXFLAGS}" export CGO_LDFLAGS="${LDFLAGS}" export GOFLAGS="-buildmode=pie -trimpath -mod=readonly -modcacherw" - go build -ldflags="-w -s -buildid='' -linkmode=external -X main.version=v${pkgver}" . + go build -ldflags="-w -s -buildid='' -linkmode=external -X github.com/charmbracelet/crush/internal/version.Version=v${pkgver}" . ./crush completion bash >./completions/crush.bash ./crush completion zsh >./completions/crush.zsh ./crush completion fish >./completions/crush.fish From b94495230b21203935ad3b043fcaa789dd49d7d7 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 26 Jan 2026 18:07:12 -0300 Subject: [PATCH 238/335] feat: add ability to drag & drop multiple file at once + support windows (#1992) Before, only dragging a single file was working. If you tried to drag & drop multiple, it would fail. Also, because Windows paste in a totally different format, it wasn't working at all before. --- internal/fsext/paste.go | 111 ++++++++++++++++++++++++++ internal/fsext/paste_test.go | 149 +++++++++++++++++++++++++++++++++++ internal/ui/model/ui.go | 49 ++++++++---- 3 files changed, 293 insertions(+), 16 deletions(-) create mode 100644 internal/fsext/paste.go create mode 100644 internal/fsext/paste_test.go diff --git a/internal/fsext/paste.go b/internal/fsext/paste.go new file mode 100644 index 0000000000000000000000000000000000000000..7e89a6443e09a2c5831ce8a072945cf7d1c4fd95 --- /dev/null +++ b/internal/fsext/paste.go @@ -0,0 +1,111 @@ +package fsext + +import ( + "runtime" + "strings" +) + +func PasteStringToPaths(s string) []string { + switch runtime.GOOS { + case "windows": + return windowsPasteStringToPaths(s) + default: + return unixPasteStringToPaths(s) + } +} + +func windowsPasteStringToPaths(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + + var ( + paths []string + current strings.Builder + inQuotes = false + ) + for i := range len(s) { + ch := s[i] + + switch { + case ch == '"': + if inQuotes { + // End of quoted section + if current.Len() > 0 { + paths = append(paths, current.String()) + current.Reset() + } + inQuotes = false + } else { + // Start of quoted section + inQuotes = true + } + case inQuotes: + current.WriteByte(ch) + } + // Skip characters outside quotes and spaces between quoted sections + } + + // Add any remaining content if quotes were properly closed + if current.Len() > 0 && !inQuotes { + paths = append(paths, current.String()) + } + + // If quotes were not closed, return empty (malformed input) + if inQuotes { + return nil + } + + return paths +} + +func unixPasteStringToPaths(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + + var ( + paths []string + current strings.Builder + escaped = false + ) + for i := range len(s) { + ch := s[i] + + switch { + case escaped: + // After a backslash, add the character as-is (including space) + current.WriteByte(ch) + escaped = false + case ch == '\\': + // Check if this backslash is at the end of the string + if i == len(s)-1 { + // Trailing backslash, treat as literal + current.WriteByte(ch) + } else { + // Start of escape sequence + escaped = true + } + case ch == ' ': + // Space separates paths (unless escaped) + if current.Len() > 0 { + paths = append(paths, current.String()) + current.Reset() + } + default: + current.WriteByte(ch) + } + } + + // Handle trailing backslash if present + if escaped { + current.WriteByte('\\') + } + + // Add the last path if any + if current.Len() > 0 { + paths = append(paths, current.String()) + } + + return paths +} diff --git a/internal/fsext/paste_test.go b/internal/fsext/paste_test.go new file mode 100644 index 0000000000000000000000000000000000000000..09f8ad4d5bebbc993193d38a7ebbb31778aba7f6 --- /dev/null +++ b/internal/fsext/paste_test.go @@ -0,0 +1,149 @@ +package fsext + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPasteStringToPaths(t *testing.T) { + t.Run("Windows", func(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "single path", + input: `"C:\path\my-screenshot-one.png"`, + expected: []string{`C:\path\my-screenshot-one.png`}, + }, + { + name: "multiple paths no spaces", + input: `"C:\path\my-screenshot-one.png" "C:\path\my-screenshot-two.png" "C:\path\my-screenshot-three.png"`, + expected: []string{`C:\path\my-screenshot-one.png`, `C:\path\my-screenshot-two.png`, `C:\path\my-screenshot-three.png`}, + }, + { + name: "sigle with spaces", + input: `"C:\path\my screenshot one.png"`, + expected: []string{`C:\path\my screenshot one.png`}, + }, + { + name: "multiple paths with spaces", + input: `"C:\path\my screenshot one.png" "C:\path\my screenshot two.png" "C:\path\my screenshot three.png"`, + expected: []string{`C:\path\my screenshot one.png`, `C:\path\my screenshot two.png`, `C:\path\my screenshot three.png`}, + }, + { + name: "empty string", + input: "", + expected: nil, + }, + { + name: "unclosed quotes", + input: `"C:\path\file.png`, + expected: nil, + }, + { + name: "text outside quotes", + input: `"C:\path\file.png" some random text "C:\path\file2.png"`, + expected: []string{`C:\path\file.png`, `C:\path\file2.png`}, + }, + { + name: "multiple spaces between paths", + input: `"C:\path\file1.png" "C:\path\file2.png"`, + expected: []string{`C:\path\file1.png`, `C:\path\file2.png`}, + }, + { + name: "just whitespace", + input: " ", + expected: nil, + }, + { + name: "consecutive quoted sections", + input: `"C:\path1""C:\path2"`, + expected: []string{`C:\path1`, `C:\path2`}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := windowsPasteStringToPaths(tt.input) + require.Equal(t, tt.expected, result) + }) + } + }) + + t.Run("Unix", func(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "single path", + input: `/path/my-screenshot.png`, + expected: []string{"/path/my-screenshot.png"}, + }, + { + name: "multiple paths no spaces", + input: `/path/screenshot-one.png /path/screenshot-two.png /path/screenshot-three.png`, + expected: []string{"/path/screenshot-one.png", "/path/screenshot-two.png", "/path/screenshot-three.png"}, + }, + { + name: "sigle with spaces", + input: `/path/my\ screenshot\ one.png`, + expected: []string{"/path/my screenshot one.png"}, + }, + { + name: "multiple paths with spaces", + input: `/path/my\ screenshot\ one.png /path/my\ screenshot\ two.png /path/my\ screenshot\ three.png`, + expected: []string{"/path/my screenshot one.png", "/path/my screenshot two.png", "/path/my screenshot three.png"}, + }, + { + name: "empty string", + input: "", + expected: nil, + }, + { + name: "double backslash escapes", + input: `/path/my\\file.png`, + expected: []string{"/path/my\\file.png"}, + }, + { + name: "trailing backslash", + input: `/path/file\`, + expected: []string{`/path/file\`}, + }, + { + name: "multiple consecutive escaped spaces", + input: `/path/file\ \ with\ \ many\ \ spaces.png`, + expected: []string{"/path/file with many spaces.png"}, + }, + { + name: "multiple unescaped spaces", + input: `/path/file1.png /path/file2.png`, + expected: []string{"/path/file1.png", "/path/file2.png"}, + }, + { + name: "just whitespace", + input: " ", + expected: nil, + }, + { + name: "tab characters", + input: "/path/file1.png\t/path/file2.png", + expected: []string{"/path/file1.png\t/path/file2.png"}, + }, + { + name: "newlines in input", + input: "/path/file1.png\n/path/file2.png", + expected: []string{"/path/file1.png\n/path/file2.png"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := unixPasteStringToPaths(tt.input) + require.Equal(t, tt.expected, result) + }) + } + }) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index cd1ad42a0dc473c31b3ff280a7a224d64d0094c2..9a0cb92aa4e96a6bf4c2aa914ba3a826420f56b0 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -29,6 +29,7 @@ import ( "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/filetracker" + "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/message" @@ -2817,29 +2818,45 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { } } - var cmd tea.Cmd - path := strings.ReplaceAll(msg.Content, "\\ ", " ") - // Try to get an image. - path, err := filepath.Abs(strings.TrimSpace(path)) - if err != nil { - m.textarea, cmd = m.textarea.Update(msg) - return cmd - } + // Attempt to parse pasted content as file paths. If possible to parse, + // all files exist and are valid, add as attachments. + // Otherwise, paste as text. + paths := fsext.PasteStringToPaths(msg.Content) + allExistsAndValid := func() bool { + for _, path := range paths { + if _, err := os.Stat(path); os.IsNotExist(err) { + return false + } - // Check if file has an allowed image extension. - isAllowedType := false - lowerPath := strings.ToLower(path) - for _, ext := range common.AllowedImageTypes { - if strings.HasSuffix(lowerPath, ext) { - isAllowedType = true - break + lowerPath := strings.ToLower(path) + isValid := false + for _, ext := range common.AllowedImageTypes { + if strings.HasSuffix(lowerPath, ext) { + isValid = true + break + } + } + if !isValid { + return false + } } + return true } - if !isAllowedType { + if !allExistsAndValid() { + var cmd tea.Cmd m.textarea, cmd = m.textarea.Update(msg) return cmd } + var cmds []tea.Cmd + for _, path := range paths { + cmds = append(cmds, m.handleFilePathPaste(path)) + } + return tea.Batch(cmds...) +} + +// handleFilePathPaste handles a pasted file path. +func (m *UI) handleFilePathPaste(path string) tea.Cmd { return func() tea.Msg { fileInfo, err := os.Stat(path) if err != nil { From 5ddf9d1068cc01d2cea20500cecd6fabf9370fb4 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 27 Jan 2026 14:11:37 +0100 Subject: [PATCH 239/335] fix: token calculation (#2004) --- internal/agent/agent.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index d5b0ed5f1cf4333922ef65c6e0a0fafcf92710a2..815ba2fa8f3c78db8de593849a83ed161e1ee008 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -372,20 +372,18 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy } currentAssistant.AddFinish(finishReason, "", "") sessionLock.Lock() - updatedSession, getSessionErr := a.sessions.Get(genCtx, call.SessionID) + defer sessionLock.Unlock() + + updatedSession, getSessionErr := a.sessions.Get(ctx, call.SessionID) if getSessionErr != nil { - sessionLock.Unlock() return getSessionErr } a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata)) - _, sessionErr := a.sessions.Save(genCtx, updatedSession) - if sessionErr == nil { - currentSession = updatedSession - } - sessionLock.Unlock() + _, sessionErr := a.sessions.Save(ctx, updatedSession) if sessionErr != nil { return sessionErr } + currentSession = updatedSession return a.messages.Update(genCtx, *currentAssistant) }, StopWhen: []fantasy.StopCondition{ @@ -898,7 +896,7 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, } session.CompletionTokens = usage.OutputTokens - session.PromptTokens = usage.InputTokens + usage.CacheCreationTokens + session.PromptTokens = usage.InputTokens + usage.CacheReadTokens } func (a *sessionAgent) Cancel(sessionID string) { From 5e384b2e8f7ba72395581d164c18e232152e1023 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 27 Jan 2026 09:12:25 -0500 Subject: [PATCH 241/335] fix(ui): ensure the message list does not scroll beyond the last item (#1993) * fix(ui): ensure the message list does not scroll beyond the last item Ensure that when scrolling down, the message list does not scroll beyond the last item, preventing empty space from appearing below the last message. * fix: lint --------- Co-authored-by: Kujtim Hoxha --- internal/ui/list/list.go | 114 +++++++++++++++++--------------------- internal/ui/model/chat.go | 4 ++ 2 files changed, 55 insertions(+), 63 deletions(-) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index a731a0a30023c451f2e1067e4e15ccb5e06ea177..0883ab2b56c5bb7ab26073301890c832e7c4e441 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -75,30 +75,24 @@ func (l *List) Gap() int { return l.gap } -// AtBottom returns whether the list is scrolled to the bottom. +// AtBottom returns whether the list is showing the last item at the bottom. func (l *List) AtBottom() bool { if len(l.items) == 0 { return true } - // Calculate total height of all items from the bottom. + // Calculate the height from offsetIdx to the end. var totalHeight int - for i := len(l.items) - 1; i >= 0; i-- { - item := l.getItem(i) - totalHeight += item.height - if l.gap > 0 && i < len(l.items)-1 { - totalHeight += l.gap - } - if totalHeight >= l.height { - // This is the expected bottom position. - expectedIdx := i - expectedLine := totalHeight - l.height - return l.offsetIdx == expectedIdx && l.offsetLine >= expectedLine + for idx := l.offsetIdx; idx < len(l.items); idx++ { + item := l.getItem(idx) + itemHeight := item.height + if l.gap > 0 && idx > l.offsetIdx { + itemHeight += l.gap } + totalHeight += itemHeight } - // All items fit in viewport - we're at bottom if at top. - return l.offsetIdx == 0 && l.offsetLine == 0 + return totalHeight-l.offsetLine <= l.height } // SetReverse shows the list in reverse order. @@ -121,6 +115,30 @@ func (l *List) Len() int { return len(l.items) } +// lastOffsetItem returns the index and line offsets of the last item that can +// be partially visible in the viewport. +func (l *List) lastOffsetItem() (int, int, int) { + var totalHeight int + var idx int + for idx = len(l.items) - 1; idx >= 0; idx-- { + item := l.getItem(idx) + itemHeight := item.height + if l.gap > 0 && idx < len(l.items)-1 { + itemHeight += l.gap + } + totalHeight += itemHeight + if totalHeight > l.height { + break + } + } + + // Calculate line offset within the item + lineOffset := max(totalHeight-l.height, 0) + idx = max(idx, 0) + + return idx, lineOffset, totalHeight +} + // getItem renders (if needed) and returns the item at the given index. func (l *List) getItem(idx int) renderedItem { if idx < 0 || idx >= len(l.items) { @@ -171,44 +189,29 @@ func (l *List) ScrollBy(lines int) { if lines > 0 { // Scroll down - // Calculate from the bottom how many lines needed to anchor the last - // item to the bottom - var totalLines int - var lastItemIdx int // the last item that can be partially visible - for i := len(l.items) - 1; i >= 0; i-- { - item := l.getItem(i) - totalLines += item.height - if l.gap > 0 && i < len(l.items)-1 { - totalLines += l.gap - } - if totalLines > l.height-1 { - lastItemIdx = i - break - } - } - - // Now scroll down by lines - var item renderedItem l.offsetLine += lines - for { - item = l.getItem(l.offsetIdx) - totalHeight := item.height + currentItem := l.getItem(l.offsetIdx) + for l.offsetLine >= currentItem.height { + l.offsetLine -= currentItem.height if l.gap > 0 { - totalHeight += l.gap - } - - if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight { - // Valid offset - break + l.offsetLine -= l.gap } // Move to next item - l.offsetLine -= totalHeight l.offsetIdx++ + if l.offsetIdx > len(l.items)-1 { + // Reached bottom + l.ScrollToBottom() + return + } + currentItem = l.getItem(l.offsetIdx) } - if l.offsetLine >= item.height { - l.offsetLine = item.height + lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem() + if l.offsetIdx > lastOffsetIdx || (l.offsetIdx == lastOffsetIdx && l.offsetLine > lastOffsetLine) { + // Clamp to bottom + l.offsetIdx = lastOffsetIdx + l.offsetLine = lastOffsetLine } } else if lines < 0 { // Scroll up @@ -408,24 +411,9 @@ func (l *List) ScrollToBottom() { return } - // Scroll to the last item - var totalHeight int - for i := len(l.items) - 1; i >= 0; i-- { - item := l.getItem(i) - totalHeight += item.height - if l.gap > 0 && i < len(l.items)-1 { - totalHeight += l.gap - } - if totalHeight >= l.height { - l.offsetIdx = i - l.offsetLine = totalHeight - l.height - break - } - } - if totalHeight < l.height { - // All items fit in the viewport - l.ScrollToTop() - } + lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem() + l.offsetIdx = lastOffsetIdx + l.offsetLine = lastOffsetLine } // ScrollToSelected scrolls the list to the selected item. diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index d009a261580eaed209c1fc15966f50f4a8b3e62d..3a743edd9d1e87b643076f114b065b2eaa2b2ca5 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -66,6 +66,10 @@ func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) { // SetSize sets the size of the chat view port. func (m *Chat) SetSize(width, height int) { m.list.SetSize(width, height) + // Anchor to bottom if we were at the bottom. + if m.list.AtBottom() { + m.list.ScrollToBottom() + } } // Len returns the number of items in the chat list. From 8d3064ffe778438dae209f3520d6449e2ec60bdf Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 27 Jan 2026 15:28:19 +0100 Subject: [PATCH 242/335] fix: layout calculations when editor has attachments (#2012) --- internal/ui/model/ui.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 9a0cb92aa4e96a6bf4c2aa914ba3a826420f56b0..7895f323ba8c8a24de1eb5895e377cbeef761d40 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2247,7 +2247,9 @@ func (m *UI) generateLayout(w, h int) layout { if !layout.editor.Empty() { // Add editor margins 1 top and bottom - layout.editor.Min.Y += 1 + if len(m.attachments.List()) == 0 { + layout.editor.Min.Y += 1 + } layout.editor.Max.Y -= 1 } From 699ae40fd953bf671e740fbc16a1b841fa566ac3 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 27 Jan 2026 16:28:41 +0100 Subject: [PATCH 243/335] fix: make the check for sidebar toggle inclusive (#2013) --- internal/ui/dialog/commands.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 444492c9f71241bf812f0a96ac18d2118919e33d..6595b56fb702069b6a0f0786ee25cd4e94f13642 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -422,7 +422,7 @@ func (c *Commands) defaultCommands() []*CommandItem { } } // Only show toggle compact mode command if window width is larger than compact breakpoint (120) - if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" { + if c.windowWidth >= sidebarCompactModeBreakpoint && c.sessionID != "" { commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{})) } if c.sessionID != "" { From 115adebe89e0166c2a15f323bb901a5f5151d208 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 27 Jan 2026 11:38:57 -0500 Subject: [PATCH 244/335] fix(ui): use setState method to change UI state and focus (#1994) This change introduces a setState method in the UI model to encapsulate the logic for changing the UI state and focus. This ensures that any time the state or focus is changed, the layout and size are updated accordingly. --- internal/ui/model/onboarding.go | 3 +- internal/ui/model/ui.go | 63 +++++++++++++++++---------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go index d18469ee822460e60544a304afebb37dac7fa0d9..9d7e9ea0882a2d0cf8508ed75f8e5d4b5edb2b03 100644 --- a/internal/ui/model/onboarding.go +++ b/internal/ui/model/onboarding.go @@ -68,8 +68,7 @@ func (m *UI) initializeProject() tea.Cmd { // skipInitializeProject skips project initialization and transitions to the landing view. func (m *UI) skipInitializeProject() tea.Cmd { // TODO: initialize the project - m.state = uiLanding - m.focus = uiFocusEditor + m.setState(uiLanding, uiFocusEditor) // mark the project as initialized return m.markProjectInitialized } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 7895f323ba8c8a24de1eb5895e377cbeef761d40..e6e54af525cb72c61a942e408dcbda9203de6cf2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -258,8 +258,6 @@ func New(com *common.Common) *UI { com: com, dialog: dialog.NewOverlay(), keyMap: keyMap, - focus: uiFocusNone, - state: uiOnboarding, textarea: ta, chat: ch, completions: comp, @@ -271,18 +269,6 @@ func New(com *common.Common) *UI { status := NewStatus(com, ui) - // set onboarding state defaults - ui.onboarding.yesInitializeSelected = true - - if !com.Config().IsConfigured() { - ui.state = uiOnboarding - } else if n, _ := config.ProjectNeedsInitialization(); n { - ui.state = uiInitialize - } else { - ui.state = uiLanding - ui.focus = uiFocusEditor - } - ui.setEditorPrompt(false) ui.randomizePlaceholders() ui.textarea.Placeholder = ui.readyPlaceholder @@ -291,6 +277,20 @@ func New(com *common.Common) *UI { // Initialize compact mode from config ui.forceCompactMode = com.Config().Options.TUI.CompactMode + // set onboarding state defaults + ui.onboarding.yesInitializeSelected = true + + desiredState := uiLanding + desiredFocus := uiFocusEditor + if !com.Config().IsConfigured() { + desiredState = uiOnboarding + } else if n, _ := config.ProjectNeedsInitialization(); n { + desiredState = uiInitialize + } + + // set initial state + ui.setState(desiredState, desiredFocus) + return ui } @@ -310,6 +310,14 @@ func (m *UI) Init() tea.Cmd { return tea.Batch(cmds...) } +// setState changes the UI state and focus. +func (m *UI) setState(state uiState, focus uiFocusState) { + m.state = state + m.focus = focus + // Changing the state may change layout, so update it. + m.updateLayoutAndSize() +} + // loadCustomCommands loads the custom commands asynchronously. func (m *UI) loadCustomCommands() tea.Cmd { return func() tea.Msg { @@ -360,10 +368,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env)) } case loadSessionMsg: - m.state = uiChat if m.forceCompactMode { m.isCompact = true } + m.setState(uiChat, m.focus) m.session = msg.session m.sessionFiles = msg.files msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID) @@ -493,7 +501,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height - m.handleCompactMode(m.width, m.height) m.updateLayoutAndSize() // XXX: We need to store cell dimensions for image rendering. m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height @@ -1212,9 +1219,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { m.dialog.CloseDialog(dialog.ModelsID) if isOnboarding { - m.state = uiLanding - m.focus = uiFocusEditor - + m.setState(uiLanding, uiFocusEditor) m.com.Config().SetupAgents() if err := m.com.App.InitCoderAgent(context.TODO()); err != nil { cmds = append(cmds, uiutil.ReportError(err)) @@ -1507,7 +1512,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.newSession() case key.Matches(msg, m.keyMap.Tab): if m.state != uiLanding { - m.focus = uiFocusMain + m.setState(m.state, uiFocusMain) m.textarea.Blur() m.chat.Focus() m.chat.SetSelected(m.chat.Len() - 1) @@ -2054,29 +2059,26 @@ func (m *UI) toggleCompactMode() tea.Cmd { return uiutil.ReportError(err) } - m.handleCompactMode(m.width, m.height) m.updateLayoutAndSize() return nil } -// handleCompactMode updates the UI state based on window size and compact mode setting. -func (m *UI) handleCompactMode(newWidth, newHeight int) { +// updateLayoutAndSize updates the layout and sizes of UI components. +func (m *UI) updateLayoutAndSize() { + // Determine if we should be in compact mode if m.state == uiChat { if m.forceCompactMode { m.isCompact = true return } - if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint { + if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint { m.isCompact = true } else { m.isCompact = false } } -} -// updateLayoutAndSize updates the layout and sizes of UI components. -func (m *UI) updateLayoutAndSize() { m.layout = m.generateLayout(m.width, m.height) m.updateSize() } @@ -2121,7 +2123,7 @@ func (m *UI) generateLayout(w, h int) layout { const landingHeaderHeight = 4 var helpKeyMap help.KeyMap = m - if m.status.ShowingAll() { + if m.status != nil && m.status.ShowingAll() { for _, row := range helpKeyMap.FullHelp() { helpHeight = max(helpHeight, len(row)) } @@ -2527,7 +2529,6 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea. if err != nil { return uiutil.ReportError(err) } - m.state = uiChat if m.forceCompactMode { m.isCompact = true } @@ -2535,6 +2536,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea. m.session = &newSession cmds = append(cmds, m.loadSession(newSession.ID)) } + m.setState(uiChat, m.focus) } // Capture session ID to avoid race with main goroutine updating m.session. @@ -2782,8 +2784,7 @@ func (m *UI) newSession() { m.session = nil m.sessionFiles = nil - m.state = uiLanding - m.focus = uiFocusEditor + m.setState(uiLanding, uiFocusEditor) m.textarea.Focus() m.chat.Blur() m.chat.ClearMessages() From c5f0e4da2baa31e865effd3da90abf7a222bb98f Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 27 Jan 2026 17:46:51 +0100 Subject: [PATCH 245/335] Handle unknown tool calls in the TUI (#2001) --- internal/ui/chat/generic.go | 98 +++++++++++++++++++++++++++++++++++++ internal/ui/chat/tools.go | 11 +---- internal/ui/model/ui.go | 5 -- 3 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 internal/ui/chat/generic.go diff --git a/internal/ui/chat/generic.go b/internal/ui/chat/generic.go new file mode 100644 index 0000000000000000000000000000000000000000..6b0ac433028daf7a06c57f85c7799250e9652f6f --- /dev/null +++ b/internal/ui/chat/generic.go @@ -0,0 +1,98 @@ +package chat + +import ( + "encoding/json" + "strings" + + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/stringext" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// GenericToolMessageItem is a message item that represents an unknown tool call. +type GenericToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*GenericToolMessageItem)(nil) + +// NewGenericToolMessageItem creates a new [GenericToolMessageItem]. +func NewGenericToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &GenericToolRenderContext{}, canceled) +} + +// GenericToolRenderContext renders unknown/generic tool messages. +type GenericToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + name := genericPrettyName(opts.ToolCall.Name) + + if opts.IsPending() { + return pendingTool(sty, name, opts.Anim) + } + + var params map[string]any + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + var toolParams []string + if len(params) > 0 { + parsed, _ := json.Marshal(params) + toolParams = append(toolParams, string(parsed)) + } + + header := toolHeader(sty, opts.Status, name, cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if !opts.HasResult() || opts.Result.Content == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + + // Handle image data. + if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") { + body := sty.Tool.Body.Render(toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType)) + return joinToolParts(header, body) + } + + // Try to parse result as JSON for pretty display. + var result json.RawMessage + var body string + if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil { + prettyResult, err := json.MarshalIndent(result, "", " ") + if err == nil { + body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent)) + } else { + body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + } + } else if looksLikeMarkdown(opts.Result.Content) { + body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent)) + } else { + body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + } + + return joinToolParts(header, body) +} + +// genericPrettyName converts a snake_case or kebab-case tool name to a +// human-readable title case name. +func genericPrettyName(name string) string { + name = strings.ReplaceAll(name, "_", " ") + name = strings.ReplaceAll(name, "-", " ") + return stringext.Capitalize(name) +} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index e10d28e061e17c636dc9e1a6cfe364ca6f220d0e..8aac1c1401fe299b24bd2cda81e18113bfd6176d 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -255,14 +255,7 @@ func NewToolMessageItem( if strings.HasPrefix(toolCall.Name, "mcp_") { item = NewMCPToolMessageItem(sty, toolCall, result, canceled) } else { - // TODO: Implement other tool items - item = newBaseToolMessageItem( - sty, - toolCall, - result, - &DefaultToolRenderContext{}, - canceled, - ) + item = NewGenericToolMessageItem(sty, toolCall, result, canceled) } } item.SetMessageID(messageID) @@ -1399,6 +1392,6 @@ func prettifyToolName(name string) string { case tools.WriteToolName: return "Write" default: - return name + return genericPrettyName(name) } } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index e6e54af525cb72c61a942e408dcbda9203de6cf2..121446524cb95fe8f443f551e14273e5327dd1a1 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1124,11 +1124,6 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { cmds = append(cmds, m.toggleCompactMode()) m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionToggleThinking: - if m.isAgentBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) - break - } - cmds = append(cmds, func() tea.Msg { cfg := m.com.Config() if cfg == nil { From 033584c7d0c88834011697d13361d13810a86d46 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 27 Jan 2026 17:47:12 +0100 Subject: [PATCH 246/335] feat: implement prompt history (#2005) --- internal/db/db.go | 20 ++++ internal/db/messages.sql.go | 82 ++++++++++++++ internal/db/querier.go | 2 + internal/db/sql/messages.sql | 12 +++ internal/message/message.go | 32 ++++++ internal/ui/model/history.go | 184 ++++++++++++++++++++++++++++++++ internal/ui/model/keys.go | 10 ++ internal/ui/model/onboarding.go | 6 +- internal/ui/model/ui.go | 61 +++++++++-- 9 files changed, 400 insertions(+), 9 deletions(-) create mode 100644 internal/ui/model/history.go diff --git a/internal/db/db.go b/internal/db/db.go index 81c3179e22f6768b2ffa2c5b4af2e10c385d5835..a4e430c720f33f4cd3c0b9710633595ef5c5fa1f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -87,6 +87,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getUsageByModelStmt, err = db.PrepareContext(ctx, getUsageByModel); err != nil { return nil, fmt.Errorf("error preparing query GetUsageByModel: %w", err) } + if q.listAllUserMessagesStmt, err = db.PrepareContext(ctx, listAllUserMessages); err != nil { + return nil, fmt.Errorf("error preparing query ListAllUserMessages: %w", err) + } if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil { return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err) } @@ -105,6 +108,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil { return nil, fmt.Errorf("error preparing query ListSessions: %w", err) } + if q.listUserMessagesBySessionStmt, err = db.PrepareContext(ctx, listUserMessagesBySession); err != nil { + return nil, fmt.Errorf("error preparing query ListUserMessagesBySession: %w", err) + } if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil { return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err) } @@ -224,6 +230,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getUsageByModelStmt: %w", cerr) } } + if q.listAllUserMessagesStmt != nil { + if cerr := q.listAllUserMessagesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAllUserMessagesStmt: %w", cerr) + } + } if q.listFilesByPathStmt != nil { if cerr := q.listFilesByPathStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr) @@ -254,6 +265,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listSessionsStmt: %w", cerr) } } + if q.listUserMessagesBySessionStmt != nil { + if cerr := q.listUserMessagesBySessionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listUserMessagesBySessionStmt: %w", cerr) + } + } if q.updateMessageStmt != nil { if cerr := q.updateMessageStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateMessageStmt: %w", cerr) @@ -329,12 +345,14 @@ type Queries struct { getUsageByDayOfWeekStmt *sql.Stmt getUsageByHourStmt *sql.Stmt getUsageByModelStmt *sql.Stmt + listAllUserMessagesStmt *sql.Stmt listFilesByPathStmt *sql.Stmt listFilesBySessionStmt *sql.Stmt listLatestSessionFilesStmt *sql.Stmt listMessagesBySessionStmt *sql.Stmt listNewFilesStmt *sql.Stmt listSessionsStmt *sql.Stmt + listUserMessagesBySessionStmt *sql.Stmt updateMessageStmt *sql.Stmt updateSessionStmt *sql.Stmt updateSessionTitleAndUsageStmt *sql.Stmt @@ -365,12 +383,14 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getUsageByDayOfWeekStmt: q.getUsageByDayOfWeekStmt, getUsageByHourStmt: q.getUsageByHourStmt, getUsageByModelStmt: q.getUsageByModelStmt, + listAllUserMessagesStmt: q.listAllUserMessagesStmt, listFilesByPathStmt: q.listFilesByPathStmt, listFilesBySessionStmt: q.listFilesBySessionStmt, listLatestSessionFilesStmt: q.listLatestSessionFilesStmt, listMessagesBySessionStmt: q.listMessagesBySessionStmt, listNewFilesStmt: q.listNewFilesStmt, listSessionsStmt: q.listSessionsStmt, + listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt, updateMessageStmt: q.updateMessageStmt, updateSessionStmt: q.updateSessionStmt, updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt, diff --git a/internal/db/messages.sql.go b/internal/db/messages.sql.go index f10b9d5e2c47ec90aec9dc0f206d4a157fa7f6b0..44e8bb366b3e864b6716d8ccefa301c86c915234 100644 --- a/internal/db/messages.sql.go +++ b/internal/db/messages.sql.go @@ -107,6 +107,47 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) { return i, err } +const listAllUserMessages = `-- name: ListAllUserMessages :many +SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message +FROM messages +WHERE role = 'user' +ORDER BY created_at DESC +` + +func (q *Queries) ListAllUserMessages(ctx context.Context) ([]Message, error) { + rows, err := q.query(ctx, q.listAllUserMessagesStmt, listAllUserMessages) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Message{} + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.Role, + &i.Parts, + &i.Model, + &i.CreatedAt, + &i.UpdatedAt, + &i.FinishedAt, + &i.Provider, + &i.IsSummaryMessage, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listMessagesBySession = `-- name: ListMessagesBySession :many SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message FROM messages @@ -148,6 +189,47 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) ( return items, nil } +const listUserMessagesBySession = `-- name: ListUserMessagesBySession :many +SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message +FROM messages +WHERE session_id = ? AND role = 'user' +ORDER BY created_at DESC +` + +func (q *Queries) ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) { + rows, err := q.query(ctx, q.listUserMessagesBySessionStmt, listUserMessagesBySession, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Message{} + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.Role, + &i.Parts, + &i.Model, + &i.CreatedAt, + &i.UpdatedAt, + &i.FinishedAt, + &i.Provider, + &i.IsSummaryMessage, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateMessage = `-- name: UpdateMessage :exec UPDATE messages SET diff --git a/internal/db/querier.go b/internal/db/querier.go index c70386690c6c42aca53a2b6682ddca0f3a0262ba..394ba1f71aea47c93956e91fcaf07e02f65098b8 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -30,12 +30,14 @@ type Querier interface { GetUsageByDayOfWeek(ctx context.Context) ([]GetUsageByDayOfWeekRow, error) GetUsageByHour(ctx context.Context) ([]GetUsageByHourRow, error) GetUsageByModel(ctx context.Context) ([]GetUsageByModelRow, error) + ListAllUserMessages(ctx context.Context) ([]Message, error) ListFilesByPath(ctx context.Context, path string) ([]File, error) ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) ListNewFiles(ctx context.Context) ([]File, error) ListSessions(ctx context.Context) ([]Session, error) + ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) UpdateMessage(ctx context.Context, arg UpdateMessageParams) error UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error diff --git a/internal/db/sql/messages.sql b/internal/db/sql/messages.sql index fc66b78c08b85c8fe1f7ec79985fb2edd4a03668..91d158eb1fb1d2280698ba09193a6298c7b129da 100644 --- a/internal/db/sql/messages.sql +++ b/internal/db/sql/messages.sql @@ -41,3 +41,15 @@ WHERE id = ?; -- name: DeleteSessionMessages :exec DELETE FROM messages WHERE session_id = ?; + +-- name: ListUserMessagesBySession :many +SELECT * +FROM messages +WHERE session_id = ? AND role = 'user' +ORDER BY created_at DESC; + +-- name: ListAllUserMessages :many +SELECT * +FROM messages +WHERE role = 'user' +ORDER BY created_at DESC; diff --git a/internal/message/message.go b/internal/message/message.go index 04eb8252bbe9a68444eba81fc581c6b49231734b..6da8827b72227602dc36c39b6a2254aba18d2b0d 100644 --- a/internal/message/message.go +++ b/internal/message/message.go @@ -26,6 +26,8 @@ type Service interface { Update(ctx context.Context, message Message) error Get(ctx context.Context, id string) (Message, error) List(ctx context.Context, sessionID string) ([]Message, error) + ListUserMessages(ctx context.Context, sessionID string) ([]Message, error) + ListAllUserMessages(ctx context.Context) ([]Message, error) Delete(ctx context.Context, id string) error DeleteSessionMessages(ctx context.Context, sessionID string) error } @@ -157,6 +159,36 @@ func (s *service) List(ctx context.Context, sessionID string) ([]Message, error) return messages, nil } +func (s *service) ListUserMessages(ctx context.Context, sessionID string) ([]Message, error) { + dbMessages, err := s.q.ListUserMessagesBySession(ctx, sessionID) + if err != nil { + return nil, err + } + messages := make([]Message, len(dbMessages)) + for i, dbMessage := range dbMessages { + messages[i], err = s.fromDBItem(dbMessage) + if err != nil { + return nil, err + } + } + return messages, nil +} + +func (s *service) ListAllUserMessages(ctx context.Context) ([]Message, error) { + dbMessages, err := s.q.ListAllUserMessages(ctx) + if err != nil { + return nil, err + } + messages := make([]Message, len(dbMessages)) + for i, dbMessage := range dbMessages { + messages[i], err = s.fromDBItem(dbMessage) + if err != nil { + return nil, err + } + } + return messages, nil +} + func (s *service) fromDBItem(item db.Message) (Message, error) { parts, err := unmarshalParts([]byte(item.Parts)) if err != nil { diff --git a/internal/ui/model/history.go b/internal/ui/model/history.go new file mode 100644 index 0000000000000000000000000000000000000000..5acc6ef5feabdab2bcb7a81ba8a60f5f224dab11 --- /dev/null +++ b/internal/ui/model/history.go @@ -0,0 +1,184 @@ +package model + +import ( + "context" + "log/slog" + + tea "charm.land/bubbletea/v2" + + "github.com/charmbracelet/crush/internal/message" +) + +// promptHistoryLoadedMsg is sent when prompt history is loaded. +type promptHistoryLoadedMsg struct { + messages []string +} + +// loadPromptHistory loads user messages for history navigation. +func (m *UI) loadPromptHistory() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + var messages []message.Message + var err error + + if m.session != nil { + messages, err = m.com.App.Messages.ListUserMessages(ctx, m.session.ID) + } else { + messages, err = m.com.App.Messages.ListAllUserMessages(ctx) + } + if err != nil { + slog.Error("failed to load prompt history", "error", err) + return promptHistoryLoadedMsg{messages: nil} + } + + texts := make([]string, 0, len(messages)) + for _, msg := range messages { + if text := msg.Content().Text; text != "" { + texts = append(texts, text) + } + } + return promptHistoryLoadedMsg{messages: texts} + } +} + +// handleHistoryUp handles up arrow for history navigation. +func (m *UI) handleHistoryUp(msg tea.Msg) tea.Cmd { + // Navigate to older history entry from cursor position (0,0). + if m.textarea.Length() == 0 || m.isAtEditorStart() { + if m.historyPrev() { + // we send this so that the textarea moves the view to the correct position + // without this the cursor will show up in the wrong place. + ta, cmd := m.textarea.Update(nil) + m.textarea = ta + return cmd + } + } + + // First move cursor to start before entering history. + if m.textarea.Line() == 0 { + m.textarea.CursorStart() + return nil + } + + // Let textarea handle normal cursor movement. + ta, cmd := m.textarea.Update(msg) + m.textarea = ta + return cmd +} + +// handleHistoryDown handles down arrow for history navigation. +func (m *UI) handleHistoryDown(msg tea.Msg) tea.Cmd { + // Navigate to newer history entry from end of text. + if m.isAtEditorEnd() { + if m.historyNext() { + // we send this so that the textarea moves the view to the correct position + // without this the cursor will show up in the wrong place. + ta, cmd := m.textarea.Update(nil) + m.textarea = ta + return cmd + } + } + + // First move cursor to end before navigating history. + if m.textarea.Line() == max(m.textarea.LineCount()-1, 0) { + m.textarea.MoveToEnd() + ta, cmd := m.textarea.Update(nil) + m.textarea = ta + return cmd + } + + // Let textarea handle normal cursor movement. + ta, cmd := m.textarea.Update(msg) + m.textarea = ta + return cmd +} + +// handleHistoryEscape handles escape for exiting history navigation. +func (m *UI) handleHistoryEscape(msg tea.Msg) tea.Cmd { + // Return to current draft when browsing history. + if m.promptHistory.index >= 0 { + m.promptHistory.index = -1 + m.textarea.Reset() + m.textarea.InsertString(m.promptHistory.draft) + ta, cmd := m.textarea.Update(nil) + m.textarea = ta + return cmd + } + + // Let textarea handle escape normally. + ta, cmd := m.textarea.Update(msg) + m.textarea = ta + return cmd +} + +// updateHistoryDraft updates history state when text is modified. +func (m *UI) updateHistoryDraft(oldValue string) { + if m.textarea.Value() != oldValue { + m.promptHistory.draft = m.textarea.Value() + m.promptHistory.index = -1 + } +} + +// historyPrev changes the text area content to the previous message in the history +// it returns false if it could not find the previous message. +func (m *UI) historyPrev() bool { + if len(m.promptHistory.messages) == 0 { + return false + } + if m.promptHistory.index == -1 { + m.promptHistory.draft = m.textarea.Value() + } + nextIndex := m.promptHistory.index + 1 + if nextIndex >= len(m.promptHistory.messages) { + return false + } + m.promptHistory.index = nextIndex + m.textarea.Reset() + m.textarea.InsertString(m.promptHistory.messages[nextIndex]) + m.textarea.MoveToBegin() + return true +} + +// historyNext changes the text area content to the next message in the history +// it returns false if it could not find the next message. +func (m *UI) historyNext() bool { + if m.promptHistory.index < 0 { + return false + } + nextIndex := m.promptHistory.index - 1 + if nextIndex < 0 { + m.promptHistory.index = -1 + m.textarea.Reset() + m.textarea.InsertString(m.promptHistory.draft) + return true + } + m.promptHistory.index = nextIndex + m.textarea.Reset() + m.textarea.InsertString(m.promptHistory.messages[nextIndex]) + return true +} + +// historyReset resets the history, but does not clear the message +// it just sets the current draft to empty and the position in the history. +func (m *UI) historyReset() { + m.promptHistory.index = -1 + m.promptHistory.draft = "" +} + +// isAtEditorStart returns true if we are at the 0 line and 0 col in the textarea. +func (m *UI) isAtEditorStart() bool { + return m.textarea.Line() == 0 && m.textarea.LineInfo().ColumnOffset == 0 +} + +// isAtEditorEnd returns true if we are in the last line and the last column in the textarea. +func (m *UI) isAtEditorEnd() bool { + lineCount := m.textarea.LineCount() + if lineCount == 0 { + return true + } + if m.textarea.Line() != lineCount-1 { + return false + } + info := m.textarea.LineInfo() + return info.CharOffset >= info.CharWidth-1 || info.CharWidth == 0 +} diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index 6e21e4dee0dbae1dffc124066b01185c7ebc9d3a..cf2fdcaa431b2a9c43a9612ef99ec8ce696216ca 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -15,6 +15,10 @@ type KeyMap struct { AttachmentDeleteMode key.Binding Escape key.Binding DeleteAllAttachments key.Binding + + // History navigation + HistoryPrev key.Binding + HistoryNext key.Binding } Chat struct { @@ -131,6 +135,12 @@ func DefaultKeyMap() KeyMap { key.WithKeys("r"), key.WithHelp("ctrl+r+r", "delete all attachments"), ) + km.Editor.HistoryPrev = key.NewBinding( + key.WithKeys("up"), + ) + km.Editor.HistoryNext = key.NewBinding( + key.WithKeys("down"), + ) km.Chat.NewSession = key.NewBinding( key.WithKeys("ctrl+n"), diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go index 9d7e9ea0882a2d0cf8508ed75f8e5d4b5edb2b03..1cd481f2f9a3625ba0ed8f12c8450265c0aa5ef0 100644 --- a/internal/ui/model/onboarding.go +++ b/internal/ui/model/onboarding.go @@ -48,9 +48,11 @@ func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) { // initializeProject starts project initialization and transitions to the landing view. func (m *UI) initializeProject() tea.Cmd { // clear the session - m.newSession() - cfg := m.com.Config() var cmds []tea.Cmd + if cmd := m.newSession(); cmd != nil { + cmds = append(cmds, cmd) + } + cfg := m.com.Config() initialize := func() tea.Msg { initPrompt, err := agent.InitializePrompt(*cfg) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 121446524cb95fe8f443f551e14273e5327dd1a1..58a6525672310684ff7950ab33c48bce3b00ddfe 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -211,6 +211,13 @@ type UI struct { // mouse highlighting related state lastClickTime time.Time + + // Prompt history for up/down navigation through previous messages. + promptHistory struct { + messages []string + index int + draft string + } } // New creates a new instance of the [UI] model. @@ -307,6 +314,8 @@ func (m *UI) Init() tea.Cmd { } // load the user commands async cmds = append(cmds, m.loadCustomCommands()) + // load prompt history async + cmds = append(cmds, m.loadPromptHistory()) return tea.Batch(cmds...) } @@ -390,6 +399,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.updateLayoutAndSize() } + // Reload prompt history for the new session. + m.historyReset() + cmds = append(cmds, m.loadPromptHistory()) case sendMessageMsg: cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...)) @@ -417,13 +429,20 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { commands.SetMCPPrompts(m.mcpPrompts) } + case promptHistoryLoadedMsg: + m.promptHistory.messages = msg.messages + m.promptHistory.index = -1 + m.promptHistory.draft = "" + case closeDialogMsg: m.dialog.CloseFrontDialog() case pubsub.Event[session.Session]: if msg.Type == pubsub.DeletedEvent { if m.session != nil && m.session.ID == msg.Payload.ID { - m.newSession() + if cmd := m.newSession(); cmd != nil { + cmds = append(cmds, cmd) + } } break } @@ -1095,7 +1114,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) break } - m.newSession() + if cmd := m.newSession(); cmd != nil { + cmds = append(cmds, cmd) + } m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionSummarize: if m.isAgentBusy() { @@ -1494,8 +1515,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } m.randomizePlaceholders() + m.historyReset() - return m.sendMessage(value, attachments...) + return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory()) case key.Matches(msg, m.keyMap.Chat.NewSession): if !m.hasSession() { break @@ -1504,7 +1526,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) break } - m.newSession() + if cmd := m.newSession(); cmd != nil { + cmds = append(cmds, cmd) + } case key.Matches(msg, m.keyMap.Tab): if m.state != uiLanding { m.setState(m.state, uiFocusMain) @@ -1524,6 +1548,21 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { ta, cmd := m.textarea.Update(msg) m.textarea = ta cmds = append(cmds, cmd) + case key.Matches(msg, m.keyMap.Editor.HistoryPrev): + cmd := m.handleHistoryUp(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + case key.Matches(msg, m.keyMap.Editor.HistoryNext): + cmd := m.handleHistoryDown(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + case key.Matches(msg, m.keyMap.Editor.Escape): + cmd := m.handleHistoryEscape(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } default: if handleGlobalKeys(msg) { // Handle global keys first before passing to textarea. @@ -1557,6 +1596,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.textarea = ta cmds = append(cmds, cmd) + // Any text modification becomes the current draft. + m.updateHistoryDraft(curValue) + // After updating textarea, check if we need to filter completions. // Skip filtering on the initial @ keystroke since items are loading async. if m.completionsOpen && msg.String() != "@" { @@ -1596,7 +1638,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { break } m.focus = uiFocusEditor - m.newSession() + if cmd := m.newSession(); cmd != nil { + cmds = append(cmds, cmd) + } case key.Matches(msg, m.keyMap.Chat.Expand): m.chat.ToggleExpandedSelectedItem() case key.Matches(msg, m.keyMap.Chat.Up): @@ -2772,9 +2816,10 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti // newSession clears the current session state and prepares for a new session. // The actual session creation happens when the user sends their first message. -func (m *UI) newSession() { +// Returns a command to reload prompt history. +func (m *UI) newSession() tea.Cmd { if !m.hasSession() { - return + return nil } m.session = nil @@ -2786,6 +2831,8 @@ func (m *UI) newSession() { m.pillsExpanded = false m.promptQueue = 0 m.pillsView = "" + m.historyReset() + return m.loadPromptHistory() } // handlePasteMsg handles a paste message. From 632666e5f926f74423bc4c2af39ed713f2327bce Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 27 Jan 2026 15:43:31 -0300 Subject: [PATCH 247/335] chore: fix typo on `crush stats` html page --- internal/cmd/stats/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/stats/index.html b/internal/cmd/stats/index.html index 4b25831f86c76f86bf405d3c9e77ddb2b7d1821e..b2822b132c6af919874523678a42ec32d3a76475 100644 --- a/internal/cmd/stats/index.html +++ b/internal/cmd/stats/index.html @@ -28,7 +28,7 @@
- Generated by {{.Username}} for {{.ProjectName}} in {{.GeneratedAt}}. + Generated by {{.Username}} for {{.ProjectName}} on {{.GeneratedAt}}.
From df2c001c27c3e46b11dbbc57e4bdf7d5215ad7f4 Mon Sep 17 00:00:00 2001 From: Amolith Date: Tue, 27 Jan 2026 12:25:00 -0700 Subject: [PATCH 248/335] fix(lsp): scope client to working directory (#1792) Files outside the working directory are now rejected by HandlesFile(), preventing external files from being opened on the LSP and triggering spurious diagnostics. Assisted-by: Claude Opus 4.5 via Crush --- internal/lsp/client.go | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/internal/lsp/client.go b/internal/lsp/client.go index d2f4ab8c6f1f495ec836198d86621a9df279457b..1f0b09bf990bd50aa06198d88aea034ef3d6453c 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -34,6 +34,9 @@ type Client struct { client *powernap.Client name string + // Working directory this LSP is scoped to. + workDir string + // File types this LSP server handles (e.g., .go, .rs, .py) fileTypes []string @@ -133,6 +136,7 @@ func (c *Client) createPowernapClient() error { } rootURI := string(protocol.URIFromPath(workDir)) + c.workDir = workDir command, err := c.resolver.ResolveValue(c.config.Command) if err != nil { @@ -305,9 +309,22 @@ type OpenFileInfo struct { URI protocol.DocumentURI } -// HandlesFile checks if this LSP client handles the given file based on its extension. +// HandlesFile checks if this LSP client handles the given file based on its +// extension and whether it's within the working directory. func (c *Client) HandlesFile(path string) bool { - // If no file types are specified, handle all files (backward compatibility) + // Check if file is within working directory. + absPath, err := filepath.Abs(path) + if err != nil { + slog.Debug("cannot resolve path", "name", c.name, "file", path, "error", err) + return false + } + relPath, err := filepath.Rel(c.workDir, absPath) + if err != nil || strings.HasPrefix(relPath, "..") { + slog.Debug("file outside workspace", "name", c.name, "file", path, "workDir", c.workDir) + return false + } + + // If no file types are specified, handle all files (backward compatibility). if len(c.fileTypes) == 0 { return true } From c81b02f440a1c442fe731122f5b2150547ab8fd3 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 27 Jan 2026 16:26:00 -0300 Subject: [PATCH 249/335] feat(lsp): auto-discover LSPs (#1834) * feat(lsp): auto-discover LSPs - auto-discover LSPs defined in powernap - faster startup by walking dir only once to check root markers from all LSPs - errors on auto-found LSPs are ignored (e.g. it might match golangci-lint-server but if you don't have it installed it shouldn't show in the list) Signed-off-by: Carlos Alexandro Becker * fix: sidebar improvement Signed-off-by: Carlos Alexandro Becker * fix: if Signed-off-by: Carlos Alexandro Becker * fix: startup, disabled Signed-off-by: Carlos Alexandro Becker * fix: lint Signed-off-by: Carlos Alexandro Becker * fix: remove unneeded func Signed-off-by: Carlos Alexandro Becker * perf: skip empty Signed-off-by: Carlos Alexandro Becker * fix: server names Signed-off-by: Carlos Alexandro Becker * fix: do not show failing non configured lsps Signed-off-by: Carlos Alexandro Becker * fix: allow to disable auto lsp Signed-off-by: Carlos Alexandro Becker * chore: update powernap Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- go.mod | 2 +- go.sum | 4 +- internal/app/app.go | 2 +- internal/app/lsp.go | 89 ++++++++++-- internal/config/config.go | 1 + internal/lsp/client.go | 79 +++++++++-- internal/lsp/filtermatching_test.go | 111 +++++++++++++++ internal/lsp/language.go | 132 ------------------ internal/lsp/rootmarkers_test.go | 37 ----- .../tui/components/chat/sidebar/sidebar.go | 2 - internal/tui/components/lsp/lsp.go | 31 ++-- 11 files changed, 273 insertions(+), 217 deletions(-) create mode 100644 internal/lsp/filtermatching_test.go delete mode 100644 internal/lsp/language.go delete mode 100644 internal/lsp/rootmarkers_test.go diff --git a/go.mod b/go.mod index 30c5613bf400e4568bf0662b6c340d371b1d4268..9281f1b44966d5ce00d19264b6ad29dfc4cb4aa4 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff github.com/charmbracelet/x/exp/strings v0.1.0 - github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b + github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 github.com/charmbracelet/x/term v0.2.2 github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec diff --git a/go.sum b/go.sum index 5f1787b0a9e5372580a3a92dfbb43e2786e582bb..c0d8fdcc25091a334f0f79fcf2e5f91247496fdc 100644 --- a/go.sum +++ b/go.sum @@ -122,8 +122,8 @@ github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9 github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ= github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM= -github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8= -github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= +github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 h1:h1XMgTkpBt9kEJ+9DkARNBXEgaigUQ0cI2Bot7Awnt8= +github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= diff --git a/internal/app/app.go b/internal/app/app.go index b186c1aeb4f7d0adbc3d0fd443b660952a4def52..ef6e636e44eeea9407557ca48f8ba9bd8eba72b2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -101,7 +101,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { app.setupEvents() // Initialize LSP clients in the background. - app.initLSPClients(ctx) + go app.initLSPClients(ctx) // Check for updates in the background. go app.checkForUpdates(ctx) diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 23a5447af92872223f91d3283cf6663aae0d1d07..39e03d3cb4f2f5a9dc7720f8ce1f7286d4efd6b2 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -3,41 +3,108 @@ package app import ( "context" "log/slog" + "os/exec" + "slices" "time" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/lsp" + powernapconfig "github.com/charmbracelet/x/powernap/pkg/config" ) // initLSPClients initializes LSP clients. func (app *App) initLSPClients(ctx context.Context) { + slog.Info("LSP clients initialization started") + + manager := powernapconfig.NewManager() + manager.LoadDefaults() + + var userConfiguredLSPs []string for name, clientConfig := range app.config.LSP { if clientConfig.Disabled { slog.Info("Skipping disabled LSP client", "name", name) + manager.RemoveServer(name) continue } - go app.createAndStartLSPClient(ctx, name, clientConfig) + + // HACK: the user might have the command name in their config, instead + // of the actual name. This finds out these cases, and adjusts the name + // accordingly. + if _, ok := manager.GetServer(name); !ok { + for sname, server := range manager.GetServers() { + if server.Command == name { + name = sname + break + } + } + } + userConfiguredLSPs = append(userConfiguredLSPs, name) + manager.AddServer(name, &powernapconfig.ServerConfig{ + Command: clientConfig.Command, + Args: clientConfig.Args, + Environment: clientConfig.Env, + FileTypes: clientConfig.FileTypes, + RootMarkers: clientConfig.RootMarkers, + InitOptions: clientConfig.InitOptions, + Settings: clientConfig.Options, + }) + } + + servers := manager.GetServers() + filtered := lsp.FilterMatching(app.config.WorkingDir(), servers) + + for _, name := range userConfiguredLSPs { + if _, ok := filtered[name]; !ok { + updateLSPState(name, lsp.StateDisabled, nil, nil, 0) + } + } + for name, server := range filtered { + if app.config.Options.AutoLSP != nil && !*app.config.Options.AutoLSP && !slices.Contains(userConfiguredLSPs, name) { + slog.Debug("Ignoring non user-define LSP client due to AutoLSP being disabled", "name", name) + continue + } + go app.createAndStartLSPClient( + ctx, name, + toOurConfig(server), + slices.Contains(userConfiguredLSPs, name), + ) } - slog.Info("LSP clients initialization started in background") } -// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher -func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig) { - slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args) +func toOurConfig(in *powernapconfig.ServerConfig) config.LSPConfig { + return config.LSPConfig{ + Command: in.Command, + Args: in.Args, + Env: in.Environment, + FileTypes: in.FileTypes, + RootMarkers: in.RootMarkers, + InitOptions: in.InitOptions, + Options: in.Settings, + } +} - // Check if any root markers exist in the working directory (config now has defaults) - if !lsp.HasRootMarkers(app.config.WorkingDir(), config.RootMarkers) { - slog.Debug("Skipping LSP client: no root markers found", "name", name, "rootMarkers", config.RootMarkers) - updateLSPState(name, lsp.StateDisabled, nil, nil, 0) - return +// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher. +func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig, userConfigured bool) { + if !userConfigured { + if _, err := exec.LookPath(config.Command); err != nil { + slog.Warn("Default LSP config skipped: server not installed", "name", name, "error", err) + return + } } - // Update state to starting + slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args) + + // Update state to starting. updateLSPState(name, lsp.StateStarting, nil, nil, 0) // Create LSP client. lspClient, err := lsp.New(ctx, name, config, app.config.Resolver()) if err != nil { + if !userConfigured { + slog.Warn("Default LSP config skipped due to error", "name", name, "error", err) + updateLSPState(name, lsp.StateDisabled, nil, nil, 0) + return + } slog.Error("Failed to create LSP client for", "name", name, "error", err) updateLSPState(name, lsp.StateError, err, nil, 0) return diff --git a/internal/config/config.go b/internal/config/config.go index eb8394e11972de4c91017a4b92e59ccee804ef0c..510685325fa779c7f53842435049478efeb389fb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -257,6 +257,7 @@ type Options struct { Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"` DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"` InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"` + AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers"` } type MCPs map[string]MCPConfig diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 1f0b09bf990bd50aa06198d88aea034ef3d6453c..98aa75966160ba97af8c431d98c642fb558e5dc7 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -13,10 +13,12 @@ import ( "sync/atomic" "time" + "github.com/bmatcuk/doublestar/v4" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/home" + powernapconfig "github.com/charmbracelet/x/powernap/pkg/config" powernap "github.com/charmbracelet/x/powernap/pkg/lsp" "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" "github.com/charmbracelet/x/powernap/pkg/transport" @@ -329,14 +331,15 @@ func (c *Client) HandlesFile(path string) bool { return true } + kind := powernap.DetectLanguage(path) name := strings.ToLower(filepath.Base(path)) for _, filetype := range c.fileTypes { suffix := strings.ToLower(filetype) if !strings.HasPrefix(suffix, ".") { suffix = "." + suffix } - if strings.HasSuffix(name, suffix) { - slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype) + if strings.HasSuffix(name, suffix) || filetype == string(kind) { + slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind) return true } } @@ -363,7 +366,7 @@ func (c *Client) OpenFile(ctx context.Context, filepath string) error { } // Notify the server about the opened document - if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(DetectLanguageID(uri)), 1, string(content)); err != nil { + if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(powernap.DetectLanguage(filepath)), 1, string(content)); err != nil { return err } @@ -574,18 +577,66 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration) } -// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory. -// Uses glob patterns to match files, allowing for more flexible matching. -func HasRootMarkers(dir string, rootMarkers []string) bool { - if len(rootMarkers) == 0 { - return true +// FilterMatching gets a list of configs and only returns the ones with +// matching root markers. +func FilterMatching(dir string, servers map[string]*powernapconfig.ServerConfig) map[string]*powernapconfig.ServerConfig { + result := map[string]*powernapconfig.ServerConfig{} + if len(servers) == 0 { + return result } - for _, pattern := range rootMarkers { - // Use fsext.GlobWithDoubleStar to find matches - matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1) - if err == nil && len(matches) > 0 { - return true + + type serverPatterns struct { + server *powernapconfig.ServerConfig + patterns []string + } + normalized := make(map[string]serverPatterns, len(servers)) + for name, server := range servers { + if len(server.RootMarkers) == 0 { + continue + } + patterns := make([]string, len(server.RootMarkers)) + for i, p := range server.RootMarkers { + patterns[i] = filepath.ToSlash(p) } + normalized[name] = serverPatterns{server: server, patterns: patterns} } - return false + + walker := fsext.NewFastGlobWalker(dir) + _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + + if walker.ShouldSkip(path) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + relPath, err := filepath.Rel(dir, path) + if err != nil { + return nil + } + relPath = filepath.ToSlash(relPath) + + for name, sp := range normalized { + for _, pattern := range sp.patterns { + matched, err := doublestar.Match(pattern, relPath) + if err != nil || !matched { + continue + } + result[name] = sp.server + delete(normalized, name) + break + } + } + + if len(normalized) == 0 { + return filepath.SkipAll + } + return nil + }) + + return result } diff --git a/internal/lsp/filtermatching_test.go b/internal/lsp/filtermatching_test.go new file mode 100644 index 0000000000000000000000000000000000000000..40c796916b73169b882404eecfb4625e7baaa85b --- /dev/null +++ b/internal/lsp/filtermatching_test.go @@ -0,0 +1,111 @@ +package lsp + +import ( + "os" + "path/filepath" + "testing" + + powernapconfig "github.com/charmbracelet/x/powernap/pkg/config" + "github.com/stretchr/testify/require" +) + +func TestFilterMatching(t *testing.T) { + t.Parallel() + + t.Run("matches servers with existing root markers", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644)) + + servers := map[string]*powernapconfig.ServerConfig{ + "gopls": {RootMarkers: []string{"go.mod", "go.work"}}, + "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}}, + "typescript-lsp": {RootMarkers: []string{"package.json", "tsconfig.json"}}, + } + + result := FilterMatching(tmpDir, servers) + + require.Contains(t, result, "gopls") + require.Contains(t, result, "rust-analyzer") + require.NotContains(t, result, "typescript-lsp") + }) + + t.Run("returns empty for empty servers", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + result := FilterMatching(tmpDir, map[string]*powernapconfig.ServerConfig{}) + + require.Empty(t, result) + }) + + t.Run("returns empty when no markers match", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + servers := map[string]*powernapconfig.ServerConfig{ + "gopls": {RootMarkers: []string{"go.mod"}}, + "python": {RootMarkers: []string{"pyproject.toml"}}, + } + + result := FilterMatching(tmpDir, servers) + + require.Empty(t, result) + }) + + t.Run("glob patterns work", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "src"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "src", "main.go"), []byte("package main"), 0o644)) + + servers := map[string]*powernapconfig.ServerConfig{ + "gopls": {RootMarkers: []string{"**/*.go"}}, + "python": {RootMarkers: []string{"**/*.py"}}, + } + + result := FilterMatching(tmpDir, servers) + + require.Contains(t, result, "gopls") + require.NotContains(t, result, "python") + }) + + t.Run("servers with empty root markers are not included", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644)) + + servers := map[string]*powernapconfig.ServerConfig{ + "gopls": {RootMarkers: []string{"go.mod"}}, + "generic": {RootMarkers: []string{}}, + } + + result := FilterMatching(tmpDir, servers) + + require.Contains(t, result, "gopls") + require.NotContains(t, result, "generic") + }) + + t.Run("stops early when all servers match", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644)) + + servers := map[string]*powernapconfig.ServerConfig{ + "gopls": {RootMarkers: []string{"go.mod"}}, + "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}}, + } + + result := FilterMatching(tmpDir, servers) + + require.Len(t, result, 2) + require.Contains(t, result, "gopls") + require.Contains(t, result, "rust-analyzer") + }) +} diff --git a/internal/lsp/language.go b/internal/lsp/language.go deleted file mode 100644 index 7d6a1517e849b6f09352447b2acb05539b3220af..0000000000000000000000000000000000000000 --- a/internal/lsp/language.go +++ /dev/null @@ -1,132 +0,0 @@ -package lsp - -import ( - "path/filepath" - "strings" - - "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" -) - -func DetectLanguageID(uri string) protocol.LanguageKind { - ext := strings.ToLower(filepath.Ext(uri)) - switch ext { - case ".abap": - return protocol.LangABAP - case ".bat": - return protocol.LangWindowsBat - case ".bib", ".bibtex": - return protocol.LangBibTeX - case ".clj": - return protocol.LangClojure - case ".coffee": - return protocol.LangCoffeescript - case ".c": - return protocol.LangC - case ".cpp", ".cxx", ".cc", ".c++": - return protocol.LangCPP - case ".cs": - return protocol.LangCSharp - case ".css": - return protocol.LangCSS - case ".d": - return protocol.LangD - case ".pas", ".pascal": - return protocol.LangDelphi - case ".diff", ".patch": - return protocol.LangDiff - case ".dart": - return protocol.LangDart - case ".dockerfile": - return protocol.LangDockerfile - case ".ex", ".exs": - return protocol.LangElixir - case ".erl", ".hrl": - return protocol.LangErlang - case ".fs", ".fsi", ".fsx", ".fsscript": - return protocol.LangFSharp - case ".gitcommit": - return protocol.LangGitCommit - case ".gitrebase": - return protocol.LangGitRebase - case ".go": - return protocol.LangGo - case ".groovy": - return protocol.LangGroovy - case ".hbs", ".handlebars": - return protocol.LangHandlebars - case ".hs": - return protocol.LangHaskell - case ".html", ".htm": - return protocol.LangHTML - case ".ini": - return protocol.LangIni - case ".java": - return protocol.LangJava - case ".js": - return protocol.LangJavaScript - case ".jsx": - return protocol.LangJavaScriptReact - case ".json": - return protocol.LangJSON - case ".tex", ".latex": - return protocol.LangLaTeX - case ".less": - return protocol.LangLess - case ".lua": - return protocol.LangLua - case ".makefile", "makefile": - return protocol.LangMakefile - case ".md", ".markdown": - return protocol.LangMarkdown - case ".m": - return protocol.LangObjectiveC - case ".mm": - return protocol.LangObjectiveCPP - case ".pl": - return protocol.LangPerl - case ".pm": - return protocol.LangPerl6 - case ".php": - return protocol.LangPHP - case ".ps1", ".psm1": - return protocol.LangPowershell - case ".pug", ".jade": - return protocol.LangPug - case ".py": - return protocol.LangPython - case ".r": - return protocol.LangR - case ".cshtml", ".razor": - return protocol.LangRazor - case ".rb": - return protocol.LangRuby - case ".rs": - return protocol.LangRust - case ".scss": - return protocol.LangSCSS - case ".sass": - return protocol.LangSASS - case ".scala": - return protocol.LangScala - case ".shader": - return protocol.LangShaderLab - case ".sh", ".bash", ".zsh", ".ksh": - return protocol.LangShellScript - case ".sql": - return protocol.LangSQL - case ".swift": - return protocol.LangSwift - case ".ts": - return protocol.LangTypeScript - case ".tsx": - return protocol.LangTypeScriptReact - case ".xml": - return protocol.LangXML - case ".xsl": - return protocol.LangXSL - case ".yaml", ".yml": - return protocol.LangYAML - default: - return protocol.LanguageKind("") // Unknown language - } -} diff --git a/internal/lsp/rootmarkers_test.go b/internal/lsp/rootmarkers_test.go deleted file mode 100644 index 7b3a3c0905799865808b9b1ae0dff992e00ed34c..0000000000000000000000000000000000000000 --- a/internal/lsp/rootmarkers_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package lsp - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestHasRootMarkers(t *testing.T) { - t.Parallel() - - // Create a temporary directory for testing - tmpDir := t.TempDir() - - // Test with empty root markers (should return true) - require.True(t, HasRootMarkers(tmpDir, []string{})) - - // Test with non-existent markers - require.False(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"})) - - // Create a go.mod file - goModPath := filepath.Join(tmpDir, "go.mod") - err := os.WriteFile(goModPath, []byte("module test"), 0o644) - require.NoError(t, err) - - // Test with existing marker - require.True(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"})) - - // Test with only non-existent markers - require.False(t, HasRootMarkers(tmpDir, []string{"package.json", "Cargo.toml"})) - - // Test with glob patterns - require.True(t, HasRootMarkers(tmpDir, []string{"*.mod"})) - require.False(t, HasRootMarkers(tmpDir, []string{"*.json"})) -} diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 9b3d52dadb9a7677bdb5db4b3a8360e7385775ba..a454605c8ee2938fa02d98d9770704388d0bd38a 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -480,8 +480,6 @@ func (m *sidebarCmp) filesBlock() string { func (m *sidebarCmp) lspBlock() string { // Limit the number of LSPs shown _, maxLSPs, _ := m.getDynamicLimits() - lspConfigs := config.Get().LSP.Sorted() - maxLSPs = min(len(lspConfigs), maxLSPs) return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{ MaxWidth: m.getMaxWidth(), diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go index f9118143cbfd9a7bf19aa569bc85448746debecd..3379c2c9acfd7e7e10d6e6777e2554d0b0db2144 100644 --- a/internal/tui/components/lsp/lsp.go +++ b/internal/tui/components/lsp/lsp.go @@ -2,6 +2,8 @@ package lsp import ( "fmt" + "maps" + "slices" "strings" "charm.land/lipgloss/v2" @@ -35,32 +37,32 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption lspList = append(lspList, section, "") } - lspConfigs := config.Get().LSP.Sorted() - if len(lspConfigs) == 0 { + // Get LSP states + lsps := slices.SortedFunc(maps.Values(app.GetLSPStates()), func(a, b app.LSPClientInfo) int { + return strings.Compare(a.Name, b.Name) + }) + if len(lsps) == 0 { lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None")) return lspList } - // Get LSP states - lspStates := app.GetLSPStates() - // Determine how many items to show - maxItems := len(lspConfigs) + maxItems := len(lsps) if opts.MaxItems > 0 { - maxItems = min(opts.MaxItems, len(lspConfigs)) + maxItems = min(opts.MaxItems, len(lsps)) } - for i, l := range lspConfigs { + for i, info := range lsps { if i >= maxItems { break } - icon, description := iconAndDescription(l, t, lspStates) + icon, description := iconAndDescription(t, info) // Calculate diagnostic counts if we have LSP clients var extraContent string if lspClients != nil { - if client, ok := lspClients.Get(l.Name); ok { + if client, ok := lspClients.Get(info.Name); ok { counts := client.GetDiagnosticCounts() errs := []string{} if counts.Error > 0 { @@ -83,7 +85,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption core.Status( core.StatusOpts{ Icon: icon.String(), - Title: l.Name, + Title: info.Name, Description: description, ExtraContent: extraContent, }, @@ -95,12 +97,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption return lspList } -func iconAndDescription(l config.LSP, t *styles.Theme, states map[string]app.LSPClientInfo) (lipgloss.Style, string) { - if l.LSP.Disabled { - return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("disabled") - } - - info := states[l.Name] +func iconAndDescription(t *styles.Theme, info app.LSPClientInfo) (lipgloss.Style, string) { switch info.State { case lsp.StateStarting: return t.ItemBusyIcon, t.S().Subtle.Render("starting...") From 50ae9f26e0d3f83fd76b0cf50b7a38925abf7548 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:27:46 +0000 Subject: [PATCH 250/335] chore: auto-update files --- internal/agent/hyper/provider.json | 2 +- schema.json | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/agent/hyper/provider.json b/internal/agent/hyper/provider.json index 5558750e38e35024615b41b71243888a1a1ebd6c..d2d0fc0d6edbce4e4e87626bcd2f09af4c9c8f14 100644 --- a/internal/agent/hyper/provider.json +++ b/internal/agent/hyper/provider.json @@ -1 +1 @@ -{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://console.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-sonnet-4-5","default_small_model_id":"claude-3-5-haiku","models":[{"id":"Kimi-K2-0905","name":"Kimi K2 0905","cost_per_1m_in":0.55,"cost_per_1m_out":2.19,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":10000,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"claude-3-5-haiku","name":"Claude 3.5 Haiku","cost_per_1m_in":0.7999999999999999,"cost_per_1m_out":4,"cost_per_1m_in_cached":1,"cost_per_1m_out_cached":0.08,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-5-sonnet","name":"Claude 3.5 Sonnet (New)","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-7-sonnet","name":"Claude 3.7 Sonnet","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-haiku-4-5","name":"Claude 4.5 Haiku","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4","name":"Claude Opus 4","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-1","name":"Claude Opus 4.1","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4","name":"Claude Sonnet 4","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","cost_per_1m_in":0.3,"cost_per_1m_out":2.5,"cost_per_1m_in_cached":0.3833,"cost_per_1m_out_cached":0.075,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":1.625,"cost_per_1m_out_cached":0.31,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"glm-4.6","name":"GLM-4.6","cost_per_1m_in":0.6,"cost_per_1m_out":2.2,"cost_per_1m_in_cached":0.11,"cost_per_1m_out_cached":0,"context_window":204800,"default_max_tokens":131072,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-4.1","name":"GPT-4.1","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-mini","name":"GPT-4.1 Mini","cost_per_1m_in":0.39999999999999997,"cost_per_1m_out":1.5999999999999999,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.09999999999999999,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-nano","name":"GPT-4.1 Nano","cost_per_1m_in":0.09999999999999999,"cost_per_1m_out":0.39999999999999997,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.024999999999999998,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o","name":"GPT-4o","cost_per_1m_in":2.5,"cost_per_1m_out":10,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":1.25,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o-mini","name":"GPT-4o-mini","cost_per_1m_in":0.15,"cost_per_1m_out":0.6,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.075,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-5","name":"GPT-5","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-codex","name":"GPT-5 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-mini","name":"GPT-5 Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-nano","name":"GPT-5 Nano","cost_per_1m_in":0.05,"cost_per_1m_out":0.4,"cost_per_1m_in_cached":0.005,"cost_per_1m_out_cached":0.005,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1","name":"GPT-5.1","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3","name":"o3","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3-mini","name":"o3 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.55,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"o4-mini","name":"o4 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.275,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"qwen3-coder-480b-a35b-instruct","name":"Qwen 3 480B Coder","cost_per_1m_in":0.82,"cost_per_1m_out":3.29,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":131072,"default_max_tokens":65536,"can_reason":false,"supports_attachments":false,"options":{}}]} \ No newline at end of file +{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://console.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-opus-4-5","default_small_model_id":"claude-haiku-4-5","models":[{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-3-flash","name":"Gemini 3 Flash","cost_per_1m_in":0.5,"cost_per_1m_out":3,"cost_per_1m_in_cached":0.049999999999999996,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gemini-3-pro-preview","name":"Gemini 3 Pro","cost_per_1m_in":2,"cost_per_1m_out":12,"cost_per_1m_in_cached":0.19999999999999998,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"glm-4.6","name":"GLM 4.6","cost_per_1m_in":0.44999999999999996,"cost_per_1m_out":1.7999999999999998,"cost_per_1m_in_cached":0.11,"cost_per_1m_out_cached":0,"context_window":200000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-4.7","name":"GLM 4.7","cost_per_1m_in":0.43,"cost_per_1m_out":1.75,"cost_per_1m_in_cached":0.08,"cost_per_1m_out_cached":0,"context_window":202752,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-5.1-codex","name":"GPT 5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT 5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT 5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT 5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2-codex","name":"GPT 5.2 Codex","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-non-reasoning","name":"Grok 4.1 Fast Non Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-reasoning","name":"Grok 4.1 Fast Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"grok-code-fast-1","name":"Grok Code Fast","cost_per_1m_in":0.2,"cost_per_1m_out":1.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.02,"context_window":256000,"default_max_tokens":20000,"can_reason":true,"supports_attachments":false,"options":{}},{"id":"kimi-k2-0905","name":"Kimi K2","cost_per_1m_in":0.55,"cost_per_1m_out":2.19,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":10000,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"kimi-k2.5","name":"Kimi K2.5","cost_per_1m_in":0.6,"cost_per_1m_out":3,"cost_per_1m_in_cached":0.09999999999999999,"cost_per_1m_out_cached":0,"context_window":262114,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}}]} \ No newline at end of file diff --git a/schema.json b/schema.json index 6eeaa40c1865ebb5e46f70964f4eba69cf47013e..0d236265a09cf301553da812c67079323c9ea20a 100644 --- a/schema.json +++ b/schema.json @@ -435,6 +435,10 @@ "CLAUDE.md", "docs/LLMs.md" ] + }, + "auto_lsp": { + "type": "boolean", + "description": "Automatically setup LSPs based on root markers" } }, "additionalProperties": false, From 99aabb0179cb65f46166b4add30156360afb0ba9 Mon Sep 17 00:00:00 2001 From: huaiyuWangh <34158348+huaiyuWangh@users.noreply.github.com> Date: Wed, 28 Jan 2026 03:30:09 +0800 Subject: [PATCH 251/335] fix: schema incorrectly marks optional fields as required (#1996) This fixes two schema validation issues: 1. tools.ls incorrectly marked as required - Changed Tools.Ls and Config.Tools from omitzero to omitempty - The invopop/jsonschema library doesn't recognize Go 1.25's omitzero tag 2. lsp.command incorrectly marked as required - Removed jsonschema:"required" tag from LSPConfig.Command - The project's own crush.json doesn't include command field for gopls After this fix, users can use minimal configurations without being forced to specify tools.ls or lsp.command fields. --- internal/config/config.go | 6 +++--- schema.json | 15 +++------------ 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 510685325fa779c7f53842435049478efeb389fb..d18d2d9c61d2f791ab9c6f9a0b7cd41029b70e60 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -187,7 +187,7 @@ type MCPConfig struct { type LSPConfig struct { Disabled bool `json:"disabled,omitempty" jsonschema:"description=Whether this LSP server is disabled,default=false"` - Command string `json:"command,omitempty" jsonschema:"required,description=Command to execute for the LSP server,example=gopls"` + Command string `json:"command,omitempty" jsonschema:"description=Command to execute for the LSP server,example=gopls"` Args []string `json:"args,omitempty" jsonschema:"description=Arguments to pass to the LSP server command"` Env map[string]string `json:"env,omitempty" jsonschema:"description=Environment variables to set to the LSP server command"` FileTypes []string `json:"filetypes,omitempty" jsonschema:"description=File types this LSP server handles,example=go,example=mod,example=rs,example=c,example=js,example=ts"` @@ -347,7 +347,7 @@ type Agent struct { } type Tools struct { - Ls ToolLs `json:"ls,omitzero"` + Ls ToolLs `json:"ls,omitempty"` } type ToolLs struct { @@ -380,7 +380,7 @@ type Config struct { Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"` - Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"` + Tools Tools `json:"tools,omitempty" jsonschema:"description=Tool configurations"` Agents map[string]Agent `json:"-"` diff --git a/schema.json b/schema.json index 0d236265a09cf301553da812c67079323c9ea20a..47b19589f29cdf6165f0b5c93a97168e3396e6bd 100644 --- a/schema.json +++ b/schema.json @@ -92,10 +92,7 @@ } }, "additionalProperties": false, - "type": "object", - "required": [ - "tools" - ] + "type": "object" }, "LSPConfig": { "properties": { @@ -162,10 +159,7 @@ } }, "additionalProperties": false, - "type": "object", - "required": [ - "command" - ] + "type": "object" }, "LSPs": { "additionalProperties": { @@ -702,10 +696,7 @@ } }, "additionalProperties": false, - "type": "object", - "required": [ - "ls" - ] + "type": "object" } } } From c6b0a8a13ec47ebddcbc2b6ab14d2006cc1d4acd Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 27 Jan 2026 17:25:57 -0500 Subject: [PATCH 252/335] refactor: terminal capability handling (#2014) --- internal/cmd/root.go | 2 +- internal/ui/common/capabilities.go | 133 +++++++++++++++++++++++++++++ internal/ui/dialog/filepicker.go | 18 ++-- internal/ui/image/image.go | 50 ----------- internal/ui/model/ui.go | 35 ++------ 5 files changed, 153 insertions(+), 85 deletions(-) create mode 100644 internal/ui/common/capabilities.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 351c9d414dd28b596374cf3a99459a1098d3c41b..577d4ccb4abaa79275a5a556c463cb52b16aab11 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -98,7 +98,6 @@ crush -y slog.Info("New UI in control!") com := common.DefaultCommon(app) ui := ui.New(com) - ui.QueryCapabilities = shouldQueryCapabilities(env) model = ui } else { ui := tui.New(app) @@ -303,6 +302,7 @@ func createDotCrushDir(dir string) error { return nil } +// TODO: Remove me after dropping the old TUI. func shouldQueryCapabilities(env uv.Environ) bool { const osVendorTypeApple = "Apple" termType := env.Getenv("TERM") diff --git a/internal/ui/common/capabilities.go b/internal/ui/common/capabilities.go new file mode 100644 index 0000000000000000000000000000000000000000..6636976d7d4f86d9283be2db759b44f948ad40f5 --- /dev/null +++ b/internal/ui/common/capabilities.go @@ -0,0 +1,133 @@ +package common + +import ( + "slices" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" + xstrings "github.com/charmbracelet/x/exp/strings" +) + +// Capabilities define different terminal capabilities supported. +type Capabilities struct { + // Profile is the terminal color profile used to determine how colors are + // rendered. + Profile colorprofile.Profile + // Columns is the number of character columns in the terminal. + Columns int + // Rows is the number of character rows in the terminal. + Rows int + // PixelX is the width of the terminal in pixels. + PixelX int + // PixelY is the height of the terminal in pixels. + PixelY int + // KittyGraphics indicates whether the terminal supports the Kitty graphics + // protocol. + KittyGraphics bool + // SixelGraphics indicates whether the terminal supports Sixel graphics. + SixelGraphics bool + // Env is the terminal environment variables. + Env uv.Environ + // TerminalVersion is the terminal version string. + TerminalVersion string + // ReportFocusEvents indicates whether the terminal supports focus events. + ReportFocusEvents bool +} + +// Update updates the capabilities based on the given message. +func (c *Capabilities) Update(msg any) { + switch m := msg.(type) { + case tea.EnvMsg: + c.Env = uv.Environ(m) + case tea.ColorProfileMsg: + c.Profile = m.Profile + case tea.WindowSizeMsg: + c.Columns = m.Width + c.Rows = m.Height + case uv.WindowPixelSizeEvent: + c.PixelX = m.Width + c.PixelY = m.Height + case uv.KittyGraphicsEvent: + c.KittyGraphics = true + case uv.PrimaryDeviceAttributesEvent: + if slices.Contains(m, 4) { + c.SixelGraphics = true + } + case tea.TerminalVersionMsg: + c.TerminalVersion = m.Name + case uv.ModeReportEvent: + switch m.Mode { + case ansi.ModeFocusEvent: + c.ReportFocusEvents = modeSupported(m.Value) + } + } +} + +// QueryCmd returns a [tea.Cmd] that queries the terminal for different +// capabilities. +func QueryCmd(env uv.Environ) tea.Cmd { + var sb strings.Builder + sb.WriteString(ansi.RequestPrimaryDeviceAttributes) + + // Queries that should only be sent to "smart" normal terminals. + shouldQueryFor := shouldQueryCapabilities(env) + if shouldQueryFor { + sb.WriteString(ansi.RequestNameVersion) + // sb.WriteString(ansi.RequestModeFocusEvent) // TODO: re-enable when we need notifications. + sb.WriteString(ansi.WindowOp(14)) // Window size in pixels + kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24") + if _, isTmux := env.LookupEnv("TMUX"); isTmux { + kittyReq = ansi.TmuxPassthrough(kittyReq) + } + sb.WriteString(kittyReq) + } + + return tea.Raw(sb.String()) +} + +// SupportsTrueColor returns true if the terminal supports true color. +func (c Capabilities) SupportsTrueColor() bool { + return c.Profile == colorprofile.TrueColor +} + +// SupportsKittyGraphics returns true if the terminal supports Kitty graphics. +func (c Capabilities) SupportsKittyGraphics() bool { + return c.KittyGraphics +} + +// SupportsSixelGraphics returns true if the terminal supports Sixel graphics. +func (c Capabilities) SupportsSixelGraphics() bool { + return c.SixelGraphics +} + +// CellSize returns the size of a single terminal cell in pixels. +func (c Capabilities) CellSize() (width, height int) { + if c.Columns == 0 || c.Rows == 0 { + return 0, 0 + } + return c.PixelX / c.Columns, c.PixelY / c.Rows +} + +func modeSupported(v ansi.ModeSetting) bool { + return v.IsSet() || v.IsReset() +} + +// kittyTerminals defines terminals supporting querying capabilities. +var kittyTerminals = []string{"alacritty", "ghostty", "kitty", "rio", "wezterm"} + +func shouldQueryCapabilities(env uv.Environ) bool { + const osVendorTypeApple = "Apple" + termType := env.Getenv("TERM") + termProg, okTermProg := env.LookupEnv("TERM_PROGRAM") + _, okSSHTTY := env.LookupEnv("SSH_TTY") + if okTermProg && strings.Contains(termProg, osVendorTypeApple) { + return false + } + return (!okTermProg && !okSSHTTY) || + (!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) || + // Terminals that do support XTVERSION. + xstrings.ContainsAnyOf(termType, kittyTerminals...) +} diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index ce4adcf8b2dc759f5eceff6ad0d7f6d1728fb7de..4b0b844e4ed869a4347af10e9d0b1b3c70a7d2f0 100644 --- a/internal/ui/dialog/filepicker.go +++ b/internal/ui/dialog/filepicker.go @@ -29,7 +29,7 @@ type FilePicker struct { imgEnc fimage.Encoding imgPrevWidth, imgPrevHeight int - cellSize fimage.CellSize + cellSizeW, cellSizeH int fp filepicker.Model help help.Model @@ -47,6 +47,14 @@ type FilePicker struct { } } +// CellSize returns the cell size used for image rendering. +func (f *FilePicker) CellSize() fimage.CellSize { + return fimage.CellSize{ + Width: f.cellSizeW, + Height: f.cellSizeH, + } +} + var _ Dialog = (*FilePicker)(nil) // NewFilePicker creates a new [FilePicker] dialog. @@ -103,12 +111,12 @@ func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) { } // SetImageCapabilities sets the image capabilities for the [FilePicker]. -func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) { +func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) { if caps != nil { - if caps.SupportsKittyGraphics { + if caps.SupportsKittyGraphics() { f.imgEnc = fimage.EncodingKitty } - f.cellSize = caps.CellSize() + f.cellSizeW, f.cellSizeH = caps.CellSize() _, f.isTmux = caps.Env.LookupEnv("TMUX") } } @@ -186,7 +194,7 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action { img, err := loadImage(selFile) if err == nil { cmds = append(cmds, tea.Sequence( - f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight, f.isTmux), + f.imgEnc.Transmit(selFile, img, f.CellSize(), f.imgPrevWidth, f.imgPrevHeight, f.isTmux), func() tea.Msg { f.previewingImage = true return nil diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index 6af76531ff5b542f180e38fb7db105e4a86b49b6..5644146fec5b1e4e1e3a96c92a315c0bf986180d 100644 --- a/internal/ui/image/image.go +++ b/internal/ui/image/image.go @@ -13,62 +13,12 @@ import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/crush/internal/uiutil" - uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi/kitty" "github.com/disintegration/imaging" paintbrush "github.com/jordanella/go-ansi-paintbrush" ) -// Capabilities represents the capabilities of displaying images on the -// terminal. -type Capabilities struct { - // Columns is the number of character columns in the terminal. - Columns int - // Rows is the number of character rows in the terminal. - Rows int - // PixelWidth is the width of the terminal in pixels. - PixelWidth int - // PixelHeight is the height of the terminal in pixels. - PixelHeight int - // SupportsKittyGraphics indicates whether the terminal supports the Kitty - // graphics protocol. - SupportsKittyGraphics bool - // Env is the terminal environment variables. - Env uv.Environ -} - -// CellSize returns the size of a single terminal cell in pixels. -func (c Capabilities) CellSize() CellSize { - return CalculateCellSize(c.PixelWidth, c.PixelHeight, c.Columns, c.Rows) -} - -// CalculateCellSize calculates the size of a single terminal cell in pixels -// based on the terminal's pixel dimensions and character dimensions. -func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellSize { - if charWidth == 0 || charHeight == 0 { - return CellSize{} - } - - return CellSize{ - Width: pixelWidth / charWidth, - Height: pixelHeight / charHeight, - } -} - -// RequestCapabilities is a [tea.Cmd] that requests the terminal to report -// its image related capabilities to the program. -func RequestCapabilities(env uv.Environ) tea.Cmd { - winOpReq := ansi.WindowOp(14) // Window size in pixels - // ID 31 is just a random ID used to detect Kitty graphics support. - kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24") - if _, isTmux := env.LookupEnv("TMUX"); isTmux { - kittyReq = ansi.TmuxPassthrough(kittyReq) - } - - return tea.Raw(winOpReq + kittyReq) -} - // TransmittedMsg is a message indicating that an image has been transmitted to // the terminal. type TransmittedMsg struct { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 58a6525672310684ff7950ab33c48bce3b00ddfe..1f2d7f86ef1953bf97e98109cbbe5d791c94122f 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -42,7 +42,6 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/completions" "github.com/charmbracelet/crush/internal/ui/dialog" - timage "github.com/charmbracelet/crush/internal/ui/image" "github.com/charmbracelet/crush/internal/ui/logo" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/uiutil" @@ -145,9 +144,8 @@ type UI struct { // terminal. sendProgressBar bool - // QueryCapabilities instructs the TUI to query for the terminal version when it - // starts. - QueryCapabilities bool + // caps hold different terminal capabilities that we query for. + caps common.Capabilities // Editor components textarea textarea.Model @@ -182,9 +180,6 @@ type UI struct { // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string - // imgCaps stores the terminal image capabilities. - imgCaps timage.Capabilities - // custom commands & mcp commands customCommands []commands.CustomCommand mcpPrompts []commands.MCPPrompt @@ -304,9 +299,6 @@ func New(com *common.Common) *UI { // Init initializes the UI model. func (m *UI) Init() tea.Cmd { var cmds []tea.Cmd - if m.QueryCapabilities { - cmds = append(cmds, tea.RequestTerminalVersion) - } if m.state == uiOnboarding { if cmd := m.openModelsDialog(); cmd != nil { cmds = append(cmds, cmd) @@ -363,19 +355,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateLayoutAndSize() } } + // Update terminal capabilities + m.caps.Update(msg) switch msg := msg.(type) { case tea.EnvMsg: // Is this Windows Terminal? if !m.sendProgressBar { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } - m.imgCaps.Env = uv.Environ(msg) - // Only query for image capabilities if the terminal is known to - // support Kitty graphics protocol. This prevents character bleeding - // on terminals that don't understand the APC escape sequences. - if m.QueryCapabilities { - cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env)) - } + cmds = append(cmds, common.QueryCmd(uv.Environ(msg))) case loadSessionMsg: if m.forceCompactMode { m.isCompact = true @@ -521,8 +509,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.updateLayoutAndSize() - // XXX: We need to store cell dimensions for image rendering. - m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height case tea.KeyboardEnhancementsMsg: m.keyenh = msg if msg.SupportsKeyDisambiguation() { @@ -689,16 +675,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.completionsOpen { m.completions.SetFiles(msg.Files) } - case uv.WindowPixelSizeEvent: - // [timage.RequestCapabilities] requests the terminal to send a window - // size event to help determine pixel dimensions. - m.imgCaps.PixelWidth = msg.Width - m.imgCaps.PixelHeight = msg.Height case uv.KittyGraphicsEvent: - // [timage.RequestCapabilities] sends a Kitty graphics query and this - // captures the response. Any response means the terminal understands - // the protocol. - m.imgCaps.SupportsKittyGraphics = true if !bytes.HasPrefix(msg.Payload, []byte("OK")) { slog.Warn("unexpected Kitty graphics response", "response", string(msg.Payload), @@ -2776,7 +2753,7 @@ func (m *UI) openFilesDialog() tea.Cmd { } filePicker, cmd := dialog.NewFilePicker(m.com) - filePicker.SetImageCapabilities(&m.imgCaps) + filePicker.SetImageCapabilities(&m.caps) m.dialog.OpenDialog(filePicker) return cmd From fea878e4d4c315f91c190d589891eddbeb8f7ac4 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 28 Jan 2026 10:41:57 -0300 Subject: [PATCH 253/335] feat(mcp): support server side instructions (#2015) * feat(mcp): support server side instructions Signed-off-by: Carlos Alexandro Becker * fix: empty lines Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- internal/agent/agent.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 815ba2fa8f3c78db8de593849a83ed161e1ee008..74a1a9f0c94483268b4b3558c7d4ca7a9899c7ef 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -32,6 +32,7 @@ import ( "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/message" @@ -167,6 +168,21 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy largeModel := a.largeModel.Get() systemPrompt := a.systemPrompt.Get() promptPrefix := a.systemPromptPrefix.Get() + var instructions strings.Builder + + for _, server := range mcp.GetStates() { + if server.State != mcp.StateConnected { + continue + } + if s := server.Client.InitializeResult().Instructions; s != "" { + instructions.WriteString(s) + instructions.WriteString("\n\n") + } + } + + if s := instructions.String(); s != "" { + systemPrompt += "\n\n\n" + s + "\n" + } if len(agentTools) > 0 { // Add Anthropic caching to the last tool. From 9602140845188f053c30d980cc85cdacb99f0f6f Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 28 Jan 2026 11:27:29 -0300 Subject: [PATCH 254/335] ci: format nix (#2009) Signed-off-by: Carlos Alexandro Becker --- .goreleaser.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index 784201677ed863e460818d98ac54e651bbfb7fee..0ba2b1eccdf6de70c3e39d9111074a84658bd2a3 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -268,6 +268,7 @@ nix: name: "Charm" email: "charmcli@users.noreply.github.com" license: fsl11Mit + formatter: nixfmt skip_upload: "{{ with .Prerelease }}true{{ end }}" extra_install: |- installManPage ./manpages/crush.1.gz From 008be3f20fedc4eeda40e6a142883fa47dc9ecba Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:42:39 -0300 Subject: [PATCH 255/335] chore(legal): @oug-t has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 5b5e74252b831d49bdec16557311a8e39de71b16..7d0cf20f6a37d57d7da19c324d836d784a881811 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1127,6 +1127,14 @@ "created_at": "2026-01-24T22:42:46Z", "repoId": 987670088, "pullRequestNo": 1978 + }, + { + "name": "oug-t", + "id": 252025851, + "comment_id": 3811704206, + "created_at": "2026-01-28T14:42:29Z", + "repoId": 987670088, + "pullRequestNo": 2022 } ] } \ No newline at end of file From 5011ba264a8b2c854d0f72d1364bb2a78f89e01e Mon Sep 17 00:00:00 2001 From: Tommy Guo Date: Wed, 28 Jan 2026 09:58:35 -0500 Subject: [PATCH 256/335] docs: improve clarity and fluency of mandarin tagline (#2022) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cd68cb962de3518cce6f86ac3513d388bf9bfcd0..6e167345dd92ffb7a4d56241e9da7258a7c89b97 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

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

-

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

+

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

Crush Demo

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