Files
strix/strix/interface/tui/internal/app/selection.go
T

285 lines
8.6 KiB
Go

package app
// In-app text selection for the chat trace, in the tmux copy-mode style:
// drag with the left mouse button to highlight text, and the plain-text
// selection lands on the clipboard when the button is released. Coordinates
// are anchored to content lines, so an active selection survives scrolling.
import (
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
)
type selectionCopiedMsg struct{ err error }
type toastExpiredMsg struct{ id int }
// toastDuration matches the old Textual notify("Copied to clipboard", timeout=2).
const toastDuration = 2 * time.Second
// showToast displays a transient notification and schedules its dismissal;
// the copy highlight is cleared together with the toast.
func (m *Model) showToast(text string) tea.Cmd {
return m.showToastFor(text, toastDuration)
}
func (m *Model) showToastFor(text string, duration time.Duration) tea.Cmd {
m.toastID++
m.toast = text
id := m.toastID
return tea.Tick(duration, func(time.Time) tea.Msg { return toastExpiredMsg{id: id} })
}
type selectionRegion int
const (
regionChat selectionRegion = iota
regionInput
)
type selectionState struct {
active bool
dragging bool
region selectionRegion
// Content-line coordinates: anchor is where the drag started, head is
// where the pointer currently is.
anchorLine, anchorCol int
headLine, headCol int
}
// bounds returns the selection in reading order: (fromLine, fromCol) to
// (toLine, toCol), with toCol exclusive.
func (s selectionState) bounds() (fromLine, fromCol, toLine, toCol int) {
if s.anchorLine < s.headLine || (s.anchorLine == s.headLine && s.anchorCol <= s.headCol) {
return s.anchorLine, s.anchorCol, s.headLine, s.headCol + 1
}
return s.headLine, s.headCol, s.anchorLine, s.anchorCol + 1
}
// styleSelected uses reverse video directly so the highlight renders on any
// terminal profile.
func styleSelected(text string) string {
return "\x1b[7m" + text + "\x1b[27m"
}
// chatContentCell maps main-view screen coordinates to a content cell inside
// the chat trace, honoring the pane border and the scroll offset.
func (m Model) chatContentCell(x, y int) (line, col int, ok bool) {
_, _, chatWidth, chatHeight := m.layout()
traceHeight := chatHeight - 2
if x < 1 || x > chatWidth-2 || y < 1 || y > traceHeight {
return 0, 0, false
}
return m.viewport.YOffset + y - 1, x - 1, true
}
// inputPromptWidth is the composer prompt ("> " / " ") column width; input
// selection coordinates are relative to the text after it.
const inputPromptWidth = 2
// inputTop returns the screen row of the composer's top border in the main view.
func (m Model) inputTop() int {
_, _, _, chatHeight := m.layout()
statusH := 0
if m.statusVisible() {
statusH = 1
}
return chatHeight + statusH
}
// inputContentCell maps main-view screen coordinates to a text cell inside
// the composer, honoring the border, padding, and prompt columns.
func (m Model) inputContentCell(x, y int) (line, col int, ok bool) {
_, _, chatWidth, _ := m.layout()
top := m.inputTop()
textLeft := 2 + inputPromptWidth // border + padding, then the prompt
if x < textLeft || x > chatWidth-2 || y <= top || y > top+m.input.Height() {
return 0, 0, false
}
return y - top - 1, x - textLeft, true
}
func (m *Model) beginSelection(region selectionRegion, line, col int) {
m.selection = selectionState{
active: true, dragging: true, region: region,
anchorLine: line, anchorCol: col,
headLine: line, headCol: col,
}
m.toast = ""
}
func (m *Model) extendSelection(line, col int) {
m.selection.headLine = max(0, line)
m.selection.headCol = max(0, col)
}
// finishSelection ends the drag and copies the highlighted text; a plain
// click (no movement) clears any previous highlight and, in the chat trace,
// toggles the clicked tool's collapsed state.
func (m *Model) finishSelection() tea.Cmd {
m.selection.dragging = false
if m.selection.anchorLine == m.selection.headLine && m.selection.anchorCol == m.selection.headCol {
region := m.selection.region
line := m.selection.anchorLine
m.selection.active = false
if region == regionChat {
m.toggleEventAtLine(line)
}
return nil
}
text := m.selectedText()
if text == "" {
m.selection.active = false
return nil
}
if m.selection.region == regionChat {
if cleaned := cleanCopiedText(text); strings.TrimSpace(cleaned) != "" {
text = cleaned
}
}
return func() tea.Msg {
return selectionCopiedMsg{err: writeClipboard(text)}
}
}
// iconPrefixes and decorativeLines port StrixTUIApp._ICON_PREFIXES and
// _DECORATIVE_LINES: UI ornaments dropped from copied chat text.
// kittyPlaceholderRune marks kitty graphics placeholder cells, which carry no
// copyable text.
const kittyPlaceholderRune = 0x10eeee
var iconPrefixes = []string{
"🐞 ", "🌐 ", "📋 ", "🧠 ", "◆ ", "◇ ", "◈ ", "→ ", "○ ", "● ", "✓ ", "✗ ",
"⚠ ", "▍ ", "▍", "┃ ", "• ", ">_ ", "</> ", "<~> ", "[ ] ", "[~] ", "[•] ",
}
var decorativeLines = map[string]bool{
"● In progress...": true,
"✓ Done": true,
"✗ Failed": true,
"✗ Error": true,
"○ Unknown": true,
}
// cleanCopiedText ports _clean_copied_text: drop decorative status lines and
// horizontal rules, and strip leading UI icons while keeping indentation.
func cleanCopiedText(text string) string {
var cleaned []string
for _, line := range strings.Split(text, "\n") {
stripped := strings.TrimLeft(line, " \t")
if decorativeLines[stripped] {
continue
}
if stripped != "" && strings.Trim(stripped, "─") == "" {
continue
}
if strings.ContainsRune(stripped, kittyPlaceholderRune) {
continue
}
out := line
for _, prefix := range iconPrefixes {
if strings.HasPrefix(stripped, prefix) {
leading := line[:len(line)-len(stripped)]
out = leading + stripped[len(prefix):]
break
}
}
cleaned = append(cleaned, out)
}
return strings.Join(cleaned, "\n")
}
// toggleEventAtLine expands or collapses the tool event rendered at the given
// chat content line.
func (m *Model) toggleEventAtLine(line int) {
for _, span := range m.eventSpans {
if line >= span.start && line <= span.end {
m.expandedEvents[span.eventID] = !m.expandedEvents[span.eventID]
m.refreshViewport()
return
}
}
}
func (m Model) selectedText() string {
fromLine, fromCol, toLine, toCol := m.selection.bounds()
source := m.viewportContent
if m.selection.region == regionInput {
source = m.inputText()
}
lines := strings.Split(source, "\n")
var out []string
for i := max(0, fromLine); i <= min(toLine, len(lines)-1); i++ {
left, right := 0, ansi.StringWidth(lines[i])
if i == fromLine {
left = fromCol
}
if i == toLine {
right = min(right, toCol)
}
out = append(out, strings.TrimRight(ansi.Strip(ansi.Cut(lines[i], left, right)), " "))
}
return strings.TrimRight(strings.Join(out, "\n"), "\n")
}
// inputText returns the composer's visible rows without the prompt columns,
// as the source for input-region selection.
func (m Model) inputText() string {
rows := strings.Split(m.input.View(), "\n")
for i, row := range rows {
rows[i] = ansi.Cut(row, inputPromptWidth, ansi.StringWidth(row))
}
return strings.Join(rows, "\n")
}
// highlightInputSelection re-styles the selected cells of the rendered
// composer, shifting columns past the prompt.
func (m Model) highlightInputSelection(view string) string {
if !m.selection.active || m.selection.region != regionInput {
return view
}
return highlightRows(view, 0, m.selection, inputPromptWidth)
}
// highlightSelection re-styles the selected cells of the visible trace chunk.
// visible holds the rows starting at content line offset.
func (m Model) highlightSelection(visible string, offset int) string {
if !m.selection.active || m.selection.region != regionChat {
return visible
}
return highlightRows(visible, offset, m.selection, 0)
}
// highlightRows applies reverse video to the selected cells; shift moves the
// selection columns right (for rows with a fixed prefix like the prompt).
func highlightRows(visible string, offset int, selection selectionState, shift int) string {
fromLine, fromCol, toLine, toCol := selection.bounds()
fromCol += shift
toCol += shift
rows := strings.Split(visible, "\n")
for i, row := range rows {
line := offset + i
if line < fromLine || line > toLine {
continue
}
width := ansi.StringWidth(row)
left, right := shift, width
if line == fromLine {
left = min(fromCol, width)
}
if line == toLine {
right = min(toCol, width)
}
if right <= left {
continue
}
rows[i] = ansi.Cut(row, 0, left) +
styleSelected(ansi.Strip(ansi.Cut(row, left, right))) +
ansi.Cut(row, right, width)
}
return strings.Join(rows, "\n")
}