Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 262 additions & 4 deletions internal/tui/components/chat/editor/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
uv "github.com/charmbracelet/ultraviolet"
)

type Editor interface {
Expand Down Expand Up @@ -67,6 +68,15 @@ type editorCmp struct {
currentQuery string
completionsStartIndex int
isCompletionsOpen bool

// Text selection
selectionStartX int
selectionStartY int
selectionEndX int
selectionEndY int
selectionActive bool
hasSelection bool
lastSelectionText string
}

var DeleteKeyMaps = DeleteAttachmentKeyMaps{
Expand Down Expand Up @@ -219,6 +229,30 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
case OpenEditorMsg:
m.textarea.SetValue(msg.Text)
m.textarea.MoveToEnd()
case tea.MouseClickMsg:
if msg.Button == tea.MouseLeft {
x, y := m.toLocalCoords(msg.X, msg.Y)
if x >= 0 && y >= 0 {
m.startSelection(x, y)
}
}
case tea.MouseMotionMsg:
if m.selectionActive {
x, y := m.toLocalCoords(msg.X, msg.Y)
m.updateSelection(x, y)
}
case tea.MouseReleaseMsg:
if msg.Button == tea.MouseLeft && m.selectionActive {
x, y := m.toLocalCoords(msg.X, msg.Y)
m.endSelection(x, y)

if m.hasSelection && m.lastSelectionText != "" {
return m, tea.Sequence(
tea.SetClipboard(m.lastSelectionText),
util.ReportInfo("Selection copied to clipboard"),
)
}
}
case tea.PasteMsg:
path := strings.ReplaceAll(string(msg), "\\ ", " ")
// try to get an image
Expand Down Expand Up @@ -304,6 +338,10 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
return m, m.openEditor(m.textarea.Value())
}
if key.Matches(msg, DeleteKeyMaps.Escape) {
if m.hasSelection {
m.clearSelection()
return m, nil
}
m.deleteMode = false
return m, nil
}
Expand All @@ -330,6 +368,11 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
if m.textarea.Focused() {
kp, ok := msg.(tea.KeyPressMsg)
if ok {
// Clear selection when user types
if m.hasSelection {
m.clearSelection()
}

if kp.String() == "space" || m.textarea.Value() == "" {
m.isCompletionsOpen = false
m.currentQuery = ""
Expand Down Expand Up @@ -424,16 +467,20 @@ func (m *editorCmp) View() string {
if m.app.Permissions.SkipRequests() {
m.textarea.Placeholder = "Yolo mode!"
}

textareaView := m.textarea.View()
if m.hasSelection {
textareaView = m.applySelection(textareaView)
}

if len(m.attachments) == 0 {
content := t.S().Base.Padding(1).Render(
m.textarea.View(),
)
content := t.S().Base.Padding(1).Render(textareaView)
return content
}
content := t.S().Base.Padding(0, 1, 1, 1).Render(
lipgloss.JoinVertical(lipgloss.Top,
m.attachmentsContent(),
m.textarea.View(),
textareaView,
),
)
return content
Expand Down Expand Up @@ -541,6 +588,217 @@ func (c *editorCmp) HasAttachments() bool {
return len(c.attachments) > 0
}

// Selection methods
func (m *editorCmp) startSelection(x, y int) {
m.selectionStartX = x
m.selectionStartY = y
m.selectionEndX = x
m.selectionEndY = y
m.selectionActive = true
m.hasSelection = false
}

func (m *editorCmp) updateSelection(x, y int) {
m.selectionEndX = x
m.selectionEndY = y
m.hasSelection = m.selectionStartX != m.selectionEndX || m.selectionStartY != m.selectionEndY
}

func (m *editorCmp) endSelection(x, y int) {
m.selectionEndX = x
m.selectionEndY = y
m.selectionActive = false
m.hasSelection = m.selectionStartX != m.selectionEndX || m.selectionStartY != m.selectionEndY
}

func (m *editorCmp) clearSelection() {
m.selectionStartX = -1
m.selectionStartY = -1
m.selectionEndX = -1
m.selectionEndY = -1
m.selectionActive = false
m.hasSelection = false
}

// toLocalCoords converts screen coordinates to local textarea coordinates
func (m *editorCmp) toLocalCoords(x, y int) (int, int) {
return x - m.x - 1, y - m.y // -1 for padding
}

func (m *editorCmp) applySelection(view string) string {
if !m.hasSelection {
return view
}

t := styles.CurrentTheme()
lines := strings.Split(view, "\n")
if len(lines) == 0 {
return view
}

// Calculate screen buffer dimensions
width := 0
for _, line := range lines {
if w := lipgloss.Width(line); w > width {
width = w
}
}

// Create and populate screen buffer
area := uv.Rect(0, 0, width, len(lines))
scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
uv.NewStyledString(view).Draw(scr, area)

// Normalize selection bounds
startX, startY, endX, endY := m.normalizeSelection()
startY, endY = m.clampY(startY, endY, scr.Height())
startX, endX = m.clampX(startX, endX, scr.Width())

const promptWidth = 4
var extractedText strings.Builder

// Apply highlighting and extract text
for y := startY; y <= endY; y++ {
lineStartX, lineEndX := m.getLineRange(y, startY, endY, startX, endX, promptWidth, scr.Width())

var lineText strings.Builder
for x := lineStartX; x < lineEndX; x++ {
cell := scr.CellAt(x, y)
if cell == nil {
lineText.WriteRune(' ')
continue
}

if cellStr := cell.String(); len(cellStr) > 0 {
lineText.WriteString(cellStr)
cell = cell.Clone()
cell.Style = cell.Style.
Background(t.TextSelection.GetBackground()).
Foreground(t.TextSelection.GetForeground())
scr.SetCell(x, y, cell)
} else {
lineText.WriteRune(' ')
}
}

if lineText.Len() > 0 {
if extractedText.Len() > 0 {
extractedText.WriteRune(' ')
}
extractedText.WriteString(lineText.String())
}
}

// Match extracted text with actual content to restore whitespace
m.lastSelectionText = m.matchToActualText(extractedText.String(), startX-promptWidth)
return scr.Render()
}

// normalizeSelection ensures start comes before end
func (m *editorCmp) normalizeSelection() (startX, startY, endX, endY int) {
startX, startY = m.selectionStartX, m.selectionStartY
endX, endY = m.selectionEndX, m.selectionEndY
if startY > endY || (startY == endY && startX > endX) {
startX, endX = endX, startX
startY, endY = endY, startY
}
return
}

// clampY clamps Y coordinates to valid screen buffer range
func (m *editorCmp) clampY(startY, endY, height int) (int, int) {
if startY < 0 {
startY = 0
}
if endY >= height {
endY = height - 1
}
return startY, endY
}

// clampX clamps X coordinates to valid screen buffer range
func (m *editorCmp) clampX(startX, endX, width int) (int, int) {
if startX < 0 {
startX = 0
}
if endX >= width {
endX = width - 1
}
return startX, endX
}

// getLineRange calculates the X range for a given line in the selection
func (m *editorCmp) getLineRange(y, startY, endY, startX, endX, promptWidth, screenWidth int) (int, int) {
if y == startY && y == endY {
return max(promptWidth, startX), endX + 1
}
if y == startY {
return max(promptWidth, startX), screenWidth
}
if y == endY {
return promptWidth, endX + 1
}
return promptWidth, screenWidth
}

// matchToActualText matches extracted text to actual textarea content
func (m *editorCmp) matchToActualText(extracted string, estimatedStartPos int) string {
actualText := m.textarea.Value()

// Strip whitespace for matching
stripWS := func(s string) string {
s = strings.ReplaceAll(s, " ", "")
s = strings.ReplaceAll(s, "\n", "")
return strings.ReplaceAll(s, "\t", "")
}

extractedPlain := stripWS(extracted)
actualTextNoSpaces := stripWS(actualText)

// Find search start position
searchStartPos := 0
charCount := 0
for i, r := range actualText {
if r == ' ' || r == '\n' || r == '\t' {
continue
}
if charCount >= estimatedStartPos {
searchStartPos = i
break
}
charCount++
}

// Search for match
searchFrom := max(0, searchStartPos-50)
if startIdx := strings.Index(actualTextNoSpaces[searchFrom:], extractedPlain); startIdx >= 0 {
startIdx += searchFrom

// Map back to original text with whitespace
charCount = 0
actualStartIdx, actualEndIdx := -1, -1
for i, r := range actualText {
if r == ' ' || r == '\n' || r == '\t' {
continue
}
if charCount == startIdx && actualStartIdx == -1 {
actualStartIdx = i
}
charCount++
if charCount == startIdx+len(extractedPlain) {
actualEndIdx = i + 1
break
}
}

if actualStartIdx >= 0 && actualEndIdx > actualStartIdx {
return actualText[actualStartIdx:actualEndIdx]
}
}

return extracted
}

func normalPromptFunc(info textarea.PromptInfo) string {
t := styles.CurrentTheme()
if info.LineNumber == 0 {
Expand Down
34 changes: 22 additions & 12 deletions internal/tui/page/chat/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,22 +190,22 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
p.focusedPane = PanelTypeChat
p.chat.Focus()
p.editor.Blur()
} else {
p.focusedPane = PanelTypeEditor
p.editor.Focus()
p.chat.Blur()
u, cmd := p.chat.Update(msg)
p.chat = u.(chat.MessageListCmp)
return p, cmd
}
u, cmd := p.chat.Update(msg)
p.chat = u.(chat.MessageListCmp)
p.focusedPane = PanelTypeEditor
p.editor.Focus()
p.chat.Blur()
u, cmd := p.editor.Update(msg)
p.editor = u.(editor.Editor)
return p, cmd
case tea.MouseMotionMsg:
if p.compact {
msg.Y -= 1
}
if msg.Button == tea.MouseLeft {
u, cmd := p.chat.Update(msg)
p.chat = u.(chat.MessageListCmp)
return p, cmd
return p.forwardToFocusedPane(msg)
}
return p, nil
case tea.MouseReleaseMsg:
Expand All @@ -216,9 +216,7 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
msg.Y -= 1
}
if msg.Button == tea.MouseLeft {
u, cmd := p.chat.Update(msg)
p.chat = u.(chat.MessageListCmp)
return p, cmd
return p.forwardToFocusedPane(msg)
}
return p, nil
case chat.SelectionCopyMsg:
Expand Down Expand Up @@ -1087,6 +1085,18 @@ func (p *chatPage) IsChatFocused() bool {
return p.focusedPane == PanelTypeChat
}

// forwardToFocusedPane forwards a message to either editor or chat based on focus
func (p *chatPage) forwardToFocusedPane(msg tea.Msg) (*chatPage, tea.Cmd) {
if p.focusedPane == PanelTypeEditor {
u, cmd := p.editor.Update(msg)
p.editor = u.(editor.Editor)
return p, cmd
}
u, cmd := p.chat.Update(msg)
p.chat = u.(chat.MessageListCmp)
return p, cmd
}

// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
// Returns true if the mouse is over the chat area, false otherwise.
func (p *chatPage) isMouseOverChat(x, y int) bool {
Expand Down