Skip to content
Closed
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
1 change: 0 additions & 1 deletion packages/tui/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ go 1.24.0
require (
github.com/BurntSushi/toml v1.5.0
github.com/alecthomas/chroma/v2 v2.18.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4
github.com/charmbracelet/glamour v0.10.0
Expand Down
2 changes: 0 additions & 2 deletions packages/tui/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=
Expand Down
91 changes: 68 additions & 23 deletions packages/tui/internal/components/chat/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type MessagesComponent interface {
GotoTop() (tea.Model, tea.Cmd)
GotoBottom() (tea.Model, tea.Cmd)
CopyLastMessage() (tea.Model, tea.Cmd)
CopySelection() (tea.Model, tea.Cmd)
}

type messagesComponent struct {
Expand Down Expand Up @@ -98,42 +99,72 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.MouseClickMsg:
slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset)
y := msg.Y + m.viewport.YOffset
if y > 0 {
m.selection = &selection{
startY: y,
startX: msg.X,
endY: -1,
endX: -1,
}
if msg.Button == tea.MouseLeft {
headerHeight := lipgloss.Height(m.renderHeader())
// Account for header and the newline separator
offset := headerHeight + 1
slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset, "offset", offset)
y := (msg.Y - offset) + m.viewport.YOffset
if y > 0 {
m.selection = &selection{
startY: y,
startX: msg.X,
endY: -1,
endX: -1,
}

slog.Info("mouse selection", "start", fmt.Sprintf("%d,%d", m.selection.startX, m.selection.startY), "end", fmt.Sprintf("%d,%d", m.selection.endX, m.selection.endY))
return m, m.renderView()
slog.Info("mouse selection", "start", fmt.Sprintf("%d,%d", m.selection.startX, m.selection.startY), "end", fmt.Sprintf("%d,%d", m.selection.endX, m.selection.endY))
return m, m.renderView()
}
} else if msg.Button == tea.MouseRight {
if m.selection != nil && len(m.clipboard) > 0 {
content := strings.Join(m.clipboard, "\n")
m.selection = nil
m.clipboard = []string{}
return m, tea.Sequence(
m.renderView(),
app.SetClipboard(content),
toast.NewSuccessToast("Copied to clipboard"),
)
}
}

case tea.MouseMotionMsg:
if m.selection != nil {
headerHeight := lipgloss.Height(m.renderHeader())
// Account for header and the newline separator
offset := headerHeight + 1
viewportRelativeY := msg.Y - offset
m.selection = &selection{
startX: m.selection.startX,
startY: m.selection.startY,
endX: msg.X + 1,
endY: msg.Y + m.viewport.YOffset,
endY: viewportRelativeY + m.viewport.YOffset,
}

// Disable tail mode during selection to allow free scrolling
m.tail = false

// Auto-scroll when selecting near edges
viewportHeight := m.viewport.Height()
scrollMargin := 2

if viewportRelativeY < scrollMargin && m.viewport.YOffset > 0 {
// Scroll up
m.viewport.LineUp(1)
} else if viewportRelativeY > viewportHeight-scrollMargin {
// Scroll down
m.viewport.LineDown(1)
}
return m, m.renderView()
}

case tea.MouseReleaseMsg:
if m.selection != nil && len(m.clipboard) > 0 {
content := strings.Join(m.clipboard, "\n")
m.selection = nil
m.clipboard = []string{}
return m, tea.Sequence(
m.renderView(),
app.SetClipboard(content),
toast.NewSuccessToast("Copied to clipboard"),
)
}
// Re-enable tail mode when selection ends
m.tail = true
// Just handle the mouse release, don't auto-copy or clear selection
// Selection state remains visible for user to manually copy if desired
return m, nil
case tea.WindowSizeMsg:
effectiveWidth := msg.Width - 4
// Clear cache on resize since width affects rendering
Expand Down Expand Up @@ -495,7 +526,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
clipboard := []string{}
var selection *selection
if m.selection != nil {
selection = m.selection.coords(lipgloss.Height(header) + 1)
selection = m.selection.coords(0)
}
for _, block := range blocks {
lines := strings.Split(block, "\n")
Expand Down Expand Up @@ -773,6 +804,20 @@ func (m *messagesComponent) CopyLastMessage() (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}

func (m *messagesComponent) CopySelection() (tea.Model, tea.Cmd) {
if m.selection != nil && len(m.clipboard) > 0 {
content := strings.Join(m.clipboard, "\n")
m.selection = nil
m.clipboard = []string{}
return m, tea.Sequence(
m.renderView(),
app.SetClipboard(content),
toast.NewSuccessToast("Copied to clipboard"),
)
}
return m, nil
}

func NewMessagesComponent(app *app.App) MessagesComponent {
vp := viewport.New()
vp.KeyMap = viewport.KeyMap{}
Expand Down
8 changes: 7 additions & 1 deletion packages/tui/internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,13 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
}

if keyString == "ctrl+alt+c" {
updatedMessages, cmd := a.messages.CopySelection()
a.messages = updatedMessages.(chat.MessagesComponent)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
}

// Fallback: suspend if ctrl+z is pressed and no user keybind matched
if keyString == "ctrl+z" {
return a, tea.Suspend
Expand Down Expand Up @@ -1045,7 +1052,6 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
updated, cmd := a.messages.CopyLastMessage()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesRevertCommand:
case commands.AppExitCommand:
return a, tea.Quit
}
Expand Down