From 27fa971aff9352dc74acf834d2a5ca87800d4f8b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 22 Oct 2025 16:22:26 -0400 Subject: [PATCH 001/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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/167] 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 0decf78c95af8e3acbe6a9e056a179afa0a38c15 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 20 Jan 2026 15:39:10 +0100 Subject: [PATCH 157/167] 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 158/167] 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 742b4d3543ed4977e2e9c578bac36cc6bcad509e Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 20 Jan 2026 18:36:59 -0500 Subject: [PATCH 159/167] 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 160/167] 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 161/167] 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 162/167] 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 d5b83c0dbbaa228bf3f783495d73e6db84489e2d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 21 Jan 2026 11:43:30 -0500 Subject: [PATCH 163/167] 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 164/167] 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 165/167] 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 166/167] 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 b93a3347acf294d59c338c8bad52d28ca9bce5ba Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 21 Jan 2026 15:53:12 -0500 Subject: [PATCH 167/167] 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())