summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-07-20 19:05:38 -0400
committerDax Raad <[email protected]>2025-07-20 19:05:38 -0400
commitadc8b90e0fc8119d0889cfe20813bd2da7a36758 (patch)
treeda98509daf5692c6f47875c428512af624b56734
parent733c9903ec61e26ac0bdced0697eb5b298cdaef3 (diff)
downloadopencode-adc8b90e0fc8119d0889cfe20813bd2da7a36758.tar.gz
opencode-adc8b90e0fc8119d0889cfe20813bd2da7a36758.zip
implement copy paste much wow can you believe we went this long without it so stupid i blame adam
-rw-r--r--packages/tui/internal/app/app.go2
-rw-r--r--packages/tui/internal/components/chat/messages.go133
-rw-r--r--packages/tui/internal/tui/tui.go2
3 files changed, 131 insertions, 6 deletions
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index 8f7e27935..b73cfc3cd 100644
--- a/packages/tui/internal/app/app.go
+++ b/packages/tui/internal/app/app.go
@@ -191,7 +191,7 @@ func (a *App) Key(commandName commands.CommandName) string {
return base(key) + muted(" "+command.Description)
}
-func (a *App) SetClipboard(text string) tea.Cmd {
+func SetClipboard(text string) tea.Cmd {
var cmds []tea.Cmd
cmds = append(cmds, func() tea.Msg {
clipboard.Write(clipboard.FmtText, []byte(text))
diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go
index 05836b25a..9b6d451b1 100644
--- a/packages/tui/internal/components/chat/messages.go
+++ b/packages/tui/internal/components/chat/messages.go
@@ -7,6 +7,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
@@ -36,6 +37,7 @@ type messagesComponent struct {
app *app.App
header string
viewport viewport.Model
+ clipboard []string
cache *PartCache
loading bool
showToolDetails bool
@@ -44,6 +46,48 @@ type messagesComponent struct {
tail bool
partCount int
lineCount int
+ selection selection
+}
+
+type selection struct {
+ startX int
+ endX int
+ startY int
+ endY int
+}
+
+func (s selection) selecting() bool {
+ return s.startX >= 0 && s.startY >= 0
+}
+
+func (s selection) coords(offset int) selection {
+ // selecting backwards
+ if s.startY > s.endY && s.endY >= 0 {
+ return selection{
+ startX: max(0, s.endX-1),
+ startY: s.endY - offset,
+ endX: s.startX + 1,
+ endY: s.startY - offset,
+ }
+ }
+
+ // selecting backwards same line
+ if s.startY == s.endY && s.startX >= s.endX {
+ slog.Info("selecting backwards same line", "start", s.startX, "end", s.endX)
+ return selection{
+ startY: s.startY - offset,
+ startX: max(0, s.endX-1),
+ endY: s.endY - offset,
+ endX: s.startX + 1,
+ }
+ }
+
+ return selection{
+ startX: s.startX,
+ startY: s.startY - offset,
+ endX: s.endX,
+ endY: s.endY - offset,
+ }
}
type ToggleToolDetailsMsg struct{}
@@ -57,6 +101,45 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
defer measure("from", fmt.Sprintf("%T", msg))
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,
+ }
+
+ 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()
+ }
+
+ case tea.MouseMotionMsg:
+ if m.selection.selecting() {
+ m.selection = selection{
+ startX: m.selection.startX,
+ startY: m.selection.startY,
+ endX: msg.X + 1,
+ endY: msg.Y + m.viewport.YOffset,
+ }
+ return m, m.renderView()
+ }
+
+ case tea.MouseReleaseMsg:
+ if m.selection.selecting() {
+ m.selection = selection{
+ startX: -1,
+ startY: -1,
+ endX: -1,
+ endY: -1,
+ }
+ return m, tea.Batch(
+ app.SetClipboard(strings.Join(m.clipboard, "\n")),
+ m.renderView(),
+ )
+ }
case tea.WindowSizeMsg:
effectiveWidth := msg.Width - 4
// Clear cache on resize since width affects rendering
@@ -101,6 +184,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.partCount = msg.partCount
m.lineCount = msg.lineCount
m.rendering = false
+ m.clipboard = msg.clipboard
m.loading = false
m.tail = m.viewport.AtBottom()
m.viewport = msg.viewport
@@ -120,6 +204,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
type renderCompleteMsg struct {
viewport viewport.Model
+ clipboard []string
header string
partCount int
lineCount int
@@ -234,7 +319,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
}
case opencode.AssistantMessage:
- messageMeasure := util.Measure("messages.Render")
hasTextPart := false
for partIndex, p := range message.Parts {
switch part := p.(type) {
@@ -366,7 +450,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
}
}
}
- messageMeasure()
}
error := ""
@@ -403,7 +486,42 @@ func (m *messagesComponent) renderView() tea.Cmd {
}
}
- content := "\n" + strings.Join(blocks, "\n\n")
+ final := []string{}
+ clipboard := []string{}
+ selection := m.selection.coords(lipgloss.Height(header) + 1)
+ hasSelection := m.selection.selecting()
+ for _, block := range blocks {
+ lines := strings.Split(block, "\n")
+ for index, line := range lines {
+ if !hasSelection || index == 0 || index == len(lines)-1 {
+ final = append(final, line)
+ continue
+ }
+ y := len(final)
+ if y >= selection.startY && y <= selection.endY {
+ left := 3
+ if y == selection.startY {
+ left = selection.startX - 2
+ }
+ left = max(3, left)
+
+ width := ansi.StringWidth(line)
+ right := width - 1
+ if y == selection.endY {
+ right = min(selection.endX-2, right)
+ }
+
+ prefix := ansi.Cut(line, 0, left)
+ middle := ansi.Strip(ansi.Cut(line, left, right))
+ suffix := ansi.Cut(line, right, width)
+ clipboard = append(clipboard, strings.TrimRight(middle, " "))
+ line = prefix + styles.NewStyle().Background(t.Accent()).Foreground(t.BackgroundPanel()).Render(ansi.Strip(middle)) + suffix
+ }
+ final = append(final, line)
+ }
+ final = append(final, "")
+ }
+ content := "\n" + strings.Join(final, "\n")
viewport.SetHeight(m.height - lipgloss.Height(header))
viewport.SetContent(content)
if tail {
@@ -412,6 +530,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
return renderCompleteMsg{
header: header,
+ clipboard: clipboard,
viewport: viewport,
partCount: partCount,
lineCount: lineCount,
@@ -634,7 +753,7 @@ func (m *messagesComponent) CopyLastMessage() (tea.Model, tea.Cmd) {
return m, nil
}
var cmds []tea.Cmd
- cmds = append(cmds, m.app.SetClipboard(lastTextPart.Text))
+ cmds = append(cmds, app.SetClipboard(lastTextPart.Text))
cmds = append(cmds, toast.NewSuccessToast("Message copied to clipboard"))
return m, tea.Batch(cmds...)
}
@@ -650,5 +769,11 @@ func NewMessagesComponent(app *app.App) MessagesComponent {
showToolDetails: true,
cache: NewPartCache(),
tail: true,
+ selection: selection{
+ startX: -1,
+ startY: -1,
+ endX: -1,
+ endY: -1,
+ },
}
}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index e8859ed57..ccac4c30c 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -829,7 +829,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
return a, toast.NewErrorToast("Failed to share session")
}
shareUrl := response.Share.URL
- cmds = append(cmds, a.app.SetClipboard(shareUrl))
+ cmds = append(cmds, app.SetClipboard(shareUrl))
cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
case commands.SessionUnshareCommand:
if a.app.Session.ID == "" {