mirror of
https://github.com/charmbracelet/crush.git
synced 2026-05-30 18:47:33 +00:00
Each frame the chat list is turned into a string with ANSI color codes that we then have to parse back into cells before drawing. When the chat list output is the same as the previous frame, we now reuse the cells we already produced and skip the parsing step. Co-Authored-By: Charm Crush <crush@charm.land>
1035 lines
29 KiB
Go
1035 lines
29 KiB
Go
package model
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
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"
|
|
"github.com/charmbracelet/crush/internal/ui/list"
|
|
uv "github.com/charmbracelet/ultraviolet"
|
|
"github.com/charmbracelet/x/ansi"
|
|
"github.com/clipperhouse/displaywidth"
|
|
"github.com/clipperhouse/uax29/v2/words"
|
|
)
|
|
|
|
// Constants for multi-click detection.
|
|
const (
|
|
doubleClickThreshold = 400 * time.Millisecond // 0.4s is typical double-click threshold
|
|
clickTolerance = 2 // x,y tolerance for double/tripple click
|
|
)
|
|
|
|
// DelayedClickMsg is sent after the double-click threshold to trigger a
|
|
// single-click action (like expansion) if no double-click occurred.
|
|
type DelayedClickMsg struct {
|
|
ClickID int
|
|
ItemIdx int
|
|
X, Y int
|
|
}
|
|
|
|
// Chat represents the chat UI model that handles chat interactions and
|
|
// messages.
|
|
type Chat struct {
|
|
com *common.Common
|
|
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
|
|
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
|
|
|
|
// Click tracking for double/triple clicks
|
|
lastClickTime time.Time
|
|
lastClickX int
|
|
lastClickY int
|
|
clickCount int
|
|
|
|
// Pending single click action (delayed to detect double-click)
|
|
pendingClickID int // Incremented on each click to invalidate old pending clicks
|
|
|
|
// follow is a flag to indicate whether the view should auto-scroll to
|
|
// bottom on new messages.
|
|
follow bool
|
|
|
|
// drawCache memoizes the decoded form of the last list.Render output so
|
|
// repeat frames with byte-identical content skip the per-cell ANSI
|
|
// reparse that uv.StyledString.Draw performs every call. See F9
|
|
// (docs/notes/2026-05-12-chat-rendering-perf.md §4.8). Bounded to one
|
|
// entry; invalidated implicitly by string inequality on the next Draw.
|
|
drawCache *chatDrawCache
|
|
}
|
|
|
|
// chatDrawCache holds the pre-decoded form of the last list.Render output.
|
|
// The cache is keyed by the rendered string and the screen's width method
|
|
// (graphemes vs wcwidth pick different decoders inside ultraviolet's
|
|
// printString, so a cached buffer is only valid for the method it was
|
|
// decoded with). The cached buffer is independent of the draw area, so
|
|
// resize / scroll changes that produce the same string still hit. We cannot
|
|
// use uv.StyledString.Lines because it bottoms out at the first iteration
|
|
// against a zero-bounds rectangle (see ultraviolet styled.go line 45 — the
|
|
// shared printString loop's `y >= bounds.Max.Y` exit applies to the
|
|
// line-building branch too). A Buffer of the rendered text's natural
|
|
// dimensions is the cheapest correct shape: StyledString.Draw runs once on
|
|
// miss to populate it, and Buffer.Draw is an O(cells) cell copy with no
|
|
// ANSI re-parse on hit.
|
|
type chatDrawCache struct {
|
|
rendered string
|
|
method ansi.Method
|
|
buf uv.ScreenBuffer
|
|
}
|
|
|
|
// 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),
|
|
pausedAnimations: make(map[string]struct{}),
|
|
}
|
|
l := list.NewList()
|
|
l.SetGap(1)
|
|
l.RegisterRenderCallback(c.applyHighlightRange)
|
|
l.RegisterRenderCallback(list.FocusedRenderCallback(l))
|
|
c.list = l
|
|
c.mouseDownItem = -1
|
|
c.mouseDragItem = -1
|
|
return c
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// The list's rendered output is cached in decoded form (see chatDrawCache) so
|
|
// that frames with byte-identical content skip the ANSI reparse that
|
|
// uv.StyledString.Draw performs on every call. The cache is keyed by the
|
|
// rendered string and the screen's width method; area / scroll changes do not
|
|
// invalidate it.
|
|
func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
|
|
rendered := m.list.Render()
|
|
method, ok := scr.WidthMethod().(ansi.Method)
|
|
if !ok {
|
|
// Width method isn't an ansi.Method (unlikely in practice — both
|
|
// TerminalScreen and ScreenBuffer store ansi.Method). Fall back
|
|
// to the uncached path so behavior matches upstream exactly.
|
|
uv.NewStyledString(rendered).Draw(scr, area)
|
|
return
|
|
}
|
|
if m.drawCache == nil ||
|
|
m.drawCache.rendered != rendered ||
|
|
m.drawCache.method != method {
|
|
m.drawCache = newChatDrawCache(rendered, method)
|
|
}
|
|
drawCachedBuffer(scr, area, m.drawCache.buf)
|
|
}
|
|
|
|
// newChatDrawCache builds a chatDrawCache for the given rendered string by
|
|
// running uv.StyledString.Draw into a fresh buffer sized to the text's
|
|
// natural bounds under the active width method. This is the only place
|
|
// ANSI decoding happens for cached frames — subsequent draws reuse buf
|
|
// via drawCachedBuffer.
|
|
//
|
|
// We can't use uv.StyledString.Bounds() here: it is hard-coded to
|
|
// ansi.GraphemeWidth, while StyledString.Draw lays cells using the
|
|
// destination buffer's WidthMethod (which we capture in `method`). For
|
|
// strings where graphemes and wcwidth disagree (emoji ZWJ sequences,
|
|
// some CJK, certain combining marks) the two answers diverge, leaving
|
|
// the cached buffer either too small (trailing cells dropped on hit) or
|
|
// too large (dead cells past the live content). Computing dimensions
|
|
// with `method.StringWidth` per line matches what printString tallies
|
|
// cell-by-cell, since both decode ANSI sequences and use the same width
|
|
// method.
|
|
func newChatDrawCache(rendered string, method ansi.Method) *chatDrawCache {
|
|
w, h := renderedBounds(rendered, method)
|
|
if w <= 0 {
|
|
w = 1
|
|
}
|
|
if h <= 0 {
|
|
h = 1
|
|
}
|
|
buf := uv.NewScreenBuffer(w, h)
|
|
buf.Method = method
|
|
uv.NewStyledString(rendered).Draw(buf, buf.Bounds())
|
|
return &chatDrawCache{
|
|
rendered: rendered,
|
|
method: method,
|
|
buf: buf,
|
|
}
|
|
}
|
|
|
|
// renderedBounds returns the (width, height) cell extent of rendered
|
|
// when laid out by method. Width is the widest line's StringWidth (which
|
|
// strips ANSI sequences and tallies cells via method, exactly like
|
|
// printString); height is the line count. Both match what
|
|
// uv.StyledString.Draw will write into a buffer whose WidthMethod is
|
|
// method, so the cache buffer is always sized to fit the live content.
|
|
func renderedBounds(rendered string, method ansi.Method) (w, h int) {
|
|
for line := range strings.SplitSeq(rendered, "\n") {
|
|
w = max(w, method.StringWidth(line))
|
|
h++
|
|
}
|
|
return w, h
|
|
}
|
|
|
|
// drawCachedBuffer blits a previously-decoded buffer into scr at area,
|
|
// mirroring uv.StyledString.Draw's screen-mode behavior for the default
|
|
// Wrap=false, Tail="" case. The clear loop matches StyledString.Draw line
|
|
// 51-56; the buf.Draw call replaces the per-cell ANSI decode that
|
|
// printString does on every uncached frame with a pure cell copy.
|
|
func drawCachedBuffer(scr uv.Screen, area uv.Rectangle, buf uv.ScreenBuffer) {
|
|
// Clear the area first to match StyledString.Draw — leftover cells
|
|
// from a previous frame outside the new content must be zeroed,
|
|
// because Buffer.Draw skips empty cells (it doesn't clear).
|
|
for y := area.Min.Y; y < area.Max.Y; y++ {
|
|
for x := area.Min.X; x < area.Max.X; x++ {
|
|
scr.SetCell(x, y, nil)
|
|
}
|
|
}
|
|
buf.Draw(scr, area)
|
|
}
|
|
|
|
// SetSize sets the size of the chat view port.
|
|
func (m *Chat) SetSize(width, height int) {
|
|
m.list.SetSize(width, height)
|
|
// Anchor to bottom if we were at the bottom.
|
|
if m.AtBottom() {
|
|
m.ScrollToBottom()
|
|
}
|
|
}
|
|
|
|
// Len returns the number of items in the chat list.
|
|
func (m *Chat) Len() int {
|
|
return m.list.Len()
|
|
}
|
|
|
|
// InvalidateRenderCaches drops cached rendered output on every message
|
|
// item so the next draw re-renders with the current styles.
|
|
func (m *Chat) InvalidateRenderCaches() {
|
|
items := make([]chat.MessageItem, 0, m.list.Len())
|
|
for i := range m.list.Len() {
|
|
if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
|
|
items = append(items, item)
|
|
}
|
|
}
|
|
chat.ClearItemCaches(items)
|
|
}
|
|
|
|
// 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
|
|
// 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...)
|
|
m.ScrollToBottom()
|
|
}
|
|
|
|
// AppendMessages appends a new message item to the chat list.
|
|
func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
|
|
items := make([]list.Item, len(msgs))
|
|
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.
|
|
func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
|
|
idx, ok := m.idInxMap[msg.ID]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// 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 tea.Batch(cmds...)
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// AtBottom returns whether the chat list is currently scrolled to the bottom.
|
|
func (m *Chat) AtBottom() bool {
|
|
return m.list.AtBottom()
|
|
}
|
|
|
|
// Follow returns whether the chat view is in follow mode (auto-scroll to
|
|
// bottom on new messages).
|
|
func (m *Chat) Follow() bool {
|
|
return m.follow
|
|
}
|
|
|
|
// ScrollToBottom scrolls the chat view to the bottom.
|
|
func (m *Chat) ScrollToBottom() {
|
|
m.list.ScrollToBottom()
|
|
m.follow = true // Enable follow mode when user scrolls to bottom
|
|
}
|
|
|
|
// ScrollToTop scrolls the chat view to the top.
|
|
func (m *Chat) ScrollToTop() {
|
|
m.list.ScrollToTop()
|
|
m.follow = false // Disable follow mode when user scrolls up
|
|
}
|
|
|
|
// ScrollBy scrolls the chat view by the given number of line deltas.
|
|
func (m *Chat) ScrollBy(lines int) {
|
|
m.list.ScrollBy(lines)
|
|
m.follow = lines > 0 && m.AtBottom() // Disable follow mode if user scrolls up
|
|
}
|
|
|
|
// ScrollToSelected scrolls the chat view to the selected item.
|
|
func (m *Chat) ScrollToSelected() {
|
|
m.list.ScrollToSelected()
|
|
m.follow = m.AtBottom() // Disable follow mode if user scrolls up
|
|
}
|
|
|
|
// ScrollToIndex scrolls the chat view to the item at the given index.
|
|
func (m *Chat) ScrollToIndex(index int) {
|
|
m.list.ScrollToIndex(index)
|
|
m.follow = m.AtBottom() // Disable follow mode if user scrolls up
|
|
}
|
|
|
|
// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
|
|
// any paused animations that are now visible.
|
|
func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
|
|
m.ScrollToTop()
|
|
return m.RestartPausedVisibleAnimations()
|
|
}
|
|
|
|
// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
|
|
// restart any paused animations that are now visible.
|
|
func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
|
|
m.ScrollToBottom()
|
|
return m.RestartPausedVisibleAnimations()
|
|
}
|
|
|
|
// 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) ScrollByAndAnimate(lines int) tea.Cmd {
|
|
m.ScrollBy(lines)
|
|
return m.RestartPausedVisibleAnimations()
|
|
}
|
|
|
|
// 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) ScrollToSelectedAndAnimate() tea.Cmd {
|
|
m.ScrollToSelected()
|
|
return m.RestartPausedVisibleAnimations()
|
|
}
|
|
|
|
// SelectedItemInView returns whether the selected item is currently in view.
|
|
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() {
|
|
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() {
|
|
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() {
|
|
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() {
|
|
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() {
|
|
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() {
|
|
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.
|
|
func (m *Chat) ClearMessages() {
|
|
m.idInxMap = make(map[string]int)
|
|
m.pausedAnimations = make(map[string]struct{})
|
|
m.list.SetItems()
|
|
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]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
item, ok := m.list.ItemAt(idx).(chat.MessageItem)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
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 {
|
|
if !expandable.ToggleExpanded() {
|
|
m.ScrollToIndex(m.list.Selected())
|
|
}
|
|
if m.AtBottom() {
|
|
m.ScrollToBottom()
|
|
}
|
|
}
|
|
}
|
|
|
|
// HandleKeyMsg handles key events for the chat component.
|
|
func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
|
|
if m.list.Focused() {
|
|
if handler, ok := m.list.SelectedItem().(chat.KeyEventHandler); ok {
|
|
return handler.HandleKeyEvent(key)
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// HandleMouseDown handles mouse down events for the chat component.
|
|
// It detects single, double, and triple clicks for text selection.
|
|
// Returns whether the click was handled and an optional command for delayed
|
|
// single-click actions.
|
|
func (m *Chat) HandleMouseDown(x, y int) (bool, tea.Cmd) {
|
|
if m.list.Len() == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
|
|
if itemIdx < 0 {
|
|
return false, nil
|
|
}
|
|
if !m.isSelectable(itemIdx) {
|
|
return false, nil
|
|
}
|
|
|
|
// Increment pending click ID to invalidate any previous pending clicks.
|
|
m.pendingClickID++
|
|
clickID := m.pendingClickID
|
|
|
|
// Detect multi-click (double/triple)
|
|
now := time.Now()
|
|
if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
|
|
abs(x-m.lastClickX) <= clickTolerance &&
|
|
abs(y-m.lastClickY) <= clickTolerance {
|
|
m.clickCount++
|
|
} else {
|
|
m.clickCount = 1
|
|
}
|
|
m.lastClickTime = now
|
|
m.lastClickX = x
|
|
m.lastClickY = y
|
|
|
|
// Select the item that was clicked
|
|
m.list.SetSelected(itemIdx)
|
|
|
|
var cmd tea.Cmd
|
|
|
|
switch m.clickCount {
|
|
case 1:
|
|
// Single click - start selection and schedule delayed click action.
|
|
m.mouseDown = true
|
|
m.mouseDownItem = itemIdx
|
|
m.mouseDownX = x
|
|
m.mouseDownY = itemY
|
|
m.mouseDragItem = itemIdx
|
|
m.mouseDragX = x
|
|
m.mouseDragY = itemY
|
|
|
|
// Schedule delayed click action (e.g., expansion) after a short delay.
|
|
// If a double-click occurs, the clickID will be invalidated.
|
|
cmd = tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
|
|
return DelayedClickMsg{
|
|
ClickID: clickID,
|
|
ItemIdx: itemIdx,
|
|
X: x,
|
|
Y: itemY,
|
|
}
|
|
})
|
|
case 2:
|
|
// Double click - select word (no delayed action)
|
|
m.selectWord(itemIdx, x, itemY)
|
|
case 3:
|
|
// Triple click - select line (no delayed action)
|
|
m.selectLine(itemIdx, itemY)
|
|
m.clickCount = 0 // Reset after triple click
|
|
}
|
|
|
|
return true, cmd
|
|
}
|
|
|
|
// HandleDelayedClick handles a delayed single-click action (like expansion).
|
|
// It only executes if the click ID matches (i.e., no double-click occurred)
|
|
// and no text selection was made (drag to select).
|
|
func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
|
|
// Ignore if this click was superseded by a newer click (double/triple).
|
|
if msg.ClickID != m.pendingClickID {
|
|
return false
|
|
}
|
|
|
|
// Don't expand if user dragged to select text.
|
|
if m.HasHighlight() {
|
|
return false
|
|
}
|
|
|
|
// Execute the click action (e.g., expansion).
|
|
selectedItem := m.list.SelectedItem()
|
|
if clickable, ok := selectedItem.(list.MouseClickable); ok {
|
|
handled := clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y)
|
|
// Toggle expansion only when the item signalled it handled the
|
|
// click. Items like AssistantMessageItem only report handled when
|
|
// the click is on their expandable region, so this avoids
|
|
// toggling expansion for clicks outside the clickable area.
|
|
if handled {
|
|
if expandable, ok := selectedItem.(chat.Expandable); ok {
|
|
if !expandable.ToggleExpanded() {
|
|
m.ScrollToIndex(m.list.Selected())
|
|
}
|
|
}
|
|
}
|
|
if m.AtBottom() {
|
|
m.ScrollToBottom()
|
|
}
|
|
return handled
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// HandleMouseUp handles mouse up events for the chat component.
|
|
func (m *Chat) HandleMouseUp(x, y int) bool {
|
|
if !m.mouseDown {
|
|
return false
|
|
}
|
|
|
|
m.mouseDown = false
|
|
return true
|
|
}
|
|
|
|
// HandleMouseDrag handles mouse drag events for the chat component.
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// HighlightContent returns the currently highlighted content based on the mouse
|
|
// selection. It returns an empty string if no content is highlighted.
|
|
func (m *Chat) HighlightContent() 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
|
|
m.mouseDownItem = -1
|
|
m.mouseDragItem = -1
|
|
m.lastClickTime = time.Time{}
|
|
m.lastClickX = 0
|
|
m.lastClickY = 0
|
|
m.clickCount = 0
|
|
m.pendingClickID++ // Invalidate any pending delayed click
|
|
}
|
|
|
|
// applyHighlightRange applies the current highlight range to the chat items.
|
|
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.SetHighlight(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
|
|
}
|
|
|
|
// selectWord selects the word at the given position within an item.
|
|
func (m *Chat) selectWord(itemIdx, x, itemY int) {
|
|
item := m.list.ItemAt(itemIdx)
|
|
if item == nil {
|
|
return
|
|
}
|
|
|
|
// Get the rendered content for this item
|
|
var rendered string
|
|
if rr, ok := item.(list.RawRenderable); ok {
|
|
rendered = rr.RawRender(m.list.Width())
|
|
} else {
|
|
rendered = item.Render(m.list.Width())
|
|
}
|
|
|
|
lines := strings.Split(rendered, "\n")
|
|
if itemY < 0 || itemY >= len(lines) {
|
|
return
|
|
}
|
|
|
|
// Adjust x for the item's left padding (border + padding) to get content column.
|
|
// The mouse x is in viewport space, but we need content space for boundary detection.
|
|
offset := chat.MessageLeftPaddingTotal
|
|
contentX := max(x-offset, 0)
|
|
|
|
line := ansi.Strip(lines[itemY])
|
|
startCol, endCol := findWordBoundaries(line, contentX)
|
|
if startCol == endCol {
|
|
// No word found at position, fallback to single click behavior
|
|
m.mouseDown = true
|
|
m.mouseDownItem = itemIdx
|
|
m.mouseDownX = x
|
|
m.mouseDownY = itemY
|
|
m.mouseDragItem = itemIdx
|
|
m.mouseDragX = x
|
|
m.mouseDragY = itemY
|
|
return
|
|
}
|
|
|
|
// Set selection to the word boundaries (convert back to viewport space).
|
|
// Keep mouseDown true so HandleMouseUp triggers the copy.
|
|
m.mouseDown = true
|
|
m.mouseDownItem = itemIdx
|
|
m.mouseDownX = startCol + offset
|
|
m.mouseDownY = itemY
|
|
m.mouseDragItem = itemIdx
|
|
m.mouseDragX = endCol + offset
|
|
m.mouseDragY = itemY
|
|
}
|
|
|
|
// selectLine selects the entire line at the given position within an item.
|
|
func (m *Chat) selectLine(itemIdx, itemY int) {
|
|
item := m.list.ItemAt(itemIdx)
|
|
if item == nil {
|
|
return
|
|
}
|
|
|
|
// Get the rendered content for this item
|
|
var rendered string
|
|
if rr, ok := item.(list.RawRenderable); ok {
|
|
rendered = rr.RawRender(m.list.Width())
|
|
} else {
|
|
rendered = item.Render(m.list.Width())
|
|
}
|
|
|
|
lines := strings.Split(rendered, "\n")
|
|
if itemY < 0 || itemY >= len(lines) {
|
|
return
|
|
}
|
|
|
|
// Get line length (stripped of ANSI codes) and account for padding.
|
|
// SetHighlight will subtract the offset, so we need to add it here.
|
|
offset := chat.MessageLeftPaddingTotal
|
|
lineLen := ansi.StringWidth(lines[itemY])
|
|
|
|
// Set selection to the entire line.
|
|
// Keep mouseDown true so HandleMouseUp triggers the copy.
|
|
m.mouseDown = true
|
|
m.mouseDownItem = itemIdx
|
|
m.mouseDownX = 0
|
|
m.mouseDownY = itemY
|
|
m.mouseDragItem = itemIdx
|
|
m.mouseDragX = lineLen + offset
|
|
m.mouseDragY = itemY
|
|
}
|
|
|
|
// findWordBoundaries finds the start and end column of the word at the given column.
|
|
// Returns (startCol, endCol) where endCol is exclusive.
|
|
func findWordBoundaries(line string, col int) (startCol, endCol int) {
|
|
if line == "" || col < 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
i := displaywidth.StringGraphemes(line)
|
|
for i.Next() {
|
|
}
|
|
|
|
// Segment the line into words using UAX#29.
|
|
lineCol := 0 // tracks the visited column widths
|
|
lastCol := 0 // tracks the start of the current token
|
|
iter := words.FromString(line)
|
|
for iter.Next() {
|
|
token := iter.Value()
|
|
tokenWidth := displaywidth.String(token)
|
|
|
|
graphemeStart := lineCol
|
|
graphemeEnd := lineCol + tokenWidth
|
|
lineCol += tokenWidth
|
|
|
|
// If clicked before this token, return the previous token boundaries.
|
|
if col < graphemeStart {
|
|
return lastCol, lastCol
|
|
}
|
|
|
|
// Update lastCol to the end of this token for next iteration.
|
|
lastCol = graphemeEnd
|
|
|
|
// If clicked within this token, return its boundaries.
|
|
if col >= graphemeStart && col < graphemeEnd {
|
|
// If clicked on whitespace, return empty selection.
|
|
if strings.TrimSpace(token) == "" {
|
|
return col, col
|
|
}
|
|
return graphemeStart, graphemeEnd
|
|
}
|
|
}
|
|
|
|
return col, col
|
|
}
|
|
|
|
// abs returns the absolute value of an integer.
|
|
func abs(x int) int {
|
|
if x < 0 {
|
|
return -x
|
|
}
|
|
return x
|
|
}
|