mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
285 lines
8.6 KiB
Go
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")
|
|
}
|