package cli import ( "strings" "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "reasonix/internal/i18n" ) // wrapTranscript wraps the joined transcript to width for the viewport, keeping // SGR balanced across wrap points. ansi.Hardwrap leaves a style that spans a // break open at the line end (e.g. a wrapped dim link tail), which bleeds the // attribute into the padding and the next row on stricter terminals (Warp). // lipgloss closes the active style at each line end and reopens it at the next. func wrapTranscript(s string, width int) string { if width <= 0 { return s } return lipgloss.NewStyle().Width(width).Render(s) } // copyToClipboard writes text through the terminal via OSC 52. That targets the // terminal-side clipboard in common SSH/tmux setups when the terminal permits it; // platform clipboard tools can instead succeed on the remote host and skip the // terminal clipboard path entirely. func copyToClipboard(text string) tea.Cmd { return tea.SetClipboard(text) } // copyNoticeTTL is how long the "copied to clipboard" status-line hint stays // visible after a selection copy (mouse drag, right-click, or Ctrl+C) before // copyNoticeExpireMsg clears it. const copyNoticeTTL = 1500 * time.Millisecond // copyNoticeExpireMsg clears the transient copy notice — but only if seq still // matches m.copyNoticeSeq, so an older copy's timer can't stomp a newer notice // (e.g. drag-copy immediately followed by a right-click re-copy). type copyNoticeExpireMsg struct{ seq int } // copySelectionWithNotice copies text to the clipboard and arms the status-line // "copied to clipboard" hint, bumping copyNoticeSeq so any in-flight expiry tick // from a prior copy is superseded rather than racing this one. func (m *chatTUI) copySelectionWithNotice(text string) tea.Cmd { m.copyNoticeSeq++ seq := m.copyNoticeSeq m.copyNoticeText = i18n.M.MouseCopiedHint return tea.Batch(copyToClipboard(text), tea.Tick(copyNoticeTTL, func(time.Time) tea.Msg { return copyNoticeExpireMsg{seq: seq} })) } // autoScrollMsg drives one step of edge-drag scrolling while a selection is held // against the top or bottom of the transcript. type autoScrollMsg struct{} func autoScrollTick() tea.Cmd { return tea.Tick(80*time.Millisecond, func(time.Time) tea.Msg { return autoScrollMsg{} }) } // edgeScrollDir reports the auto-scroll direction for a drag at screen row y in // a viewport of `height` rows: -1 at the top edge, +1 at the bottom, 0 between. func edgeScrollDir(y, height int) int { switch { case y <= 0: return -1 case y >= height-1: return 1 default: return 0 } } // selPos is a caret position in the wrapped transcript: a content-line index // (absolute, scroll-independent) and a visual column. type selPos struct{ line, col int } // selection is the live left-drag text selection over the transcript. anchor is // where the drag began, head where it currently is; active gates rendering and // copy. Coordinates are absolute content lines so scrolling never moves them. type selection struct { active bool anchor, head selPos } func (s selection) ordered() (start, end selPos) { if s.anchor.line > s.head.line || (s.anchor.line == s.head.line && s.anchor.col > s.head.col) { return s.head, s.anchor } return s.anchor, s.head } func (s selection) empty() bool { return s.anchor == s.head } var ( selStyle = lipgloss.NewStyle().Reverse(true) scrollThumbStyle lipgloss.Style scrollTrackStyle lipgloss.Style ) // renderTranscript draws the viewport's visible window with a scrollbar in the // last column and the active selection reverse-highlighted. The content lines // (m.wrappedLines) are already padded to cw by wrapTranscript, so this stays // cheap per frame — important because a drag re-renders on every mouse move. func (m chatTUI) renderTranscript() string { h := m.viewport.Height() if h <= 0 { return "" } cw := m.viewport.Width() // content width; the scrollbar occupies one more column lines := m.wrappedLines total := len(lines) yoff := m.viewport.YOffset() start, end := m.sel.ordered() thumbStart, thumbSize := scrollbarThumb(h, yoff, total) blank := strings.Repeat(" ", cw) rows := make([]string, h) bar := make([]string, h) for r := 0; r < h; r++ { idx := yoff + r line := blank // off-content rows fill to width if idx >= 0 && idx < total { line = lines[idx] // already cw-wide from wrapTranscript } if m.sel.active && !m.sel.empty() { if lo, hi, ok := selSpan(idx, start, end, cw); ok { line = lipgloss.StyleRanges(line, lipgloss.NewRange(lo, hi, selStyle)) } } rows[r] = line bar[r] = scrollbarCell(r, total, h, thumbStart, thumbSize) } return lipgloss.JoinHorizontal(lipgloss.Top, strings.Join(rows, "\n"), strings.Join(bar, "\n")) } // selSpan returns the [lo, hi) visual-column span of the selection on content // line idx (false when the line is outside the selection). cw bounds the span // so a multi-line selection highlights through the right edge. func selSpan(idx int, start, end selPos, cw int) (lo, hi int, ok bool) { if idx < start.line || idx > end.line { return 0, 0, false } lo, hi = 0, cw if idx == start.line { lo = start.col } if idx == end.line { hi = end.col } if hi > cw { hi = cw } if lo >= hi { return 0, 0, false } return lo, hi, true } // selectedText is the plain (ANSI-stripped) text of the active selection, lines // joined with '\n', for the clipboard. func (m chatTUI) selectedText() string { if !m.sel.active || m.sel.empty() { return "" } lines := m.wrappedLines start, end := m.sel.ordered() var out []string for idx := start.line; idx <= end.line && idx < len(lines); idx++ { lo, hi := 0, ansi.StringWidth(lines[idx]) if idx == start.line { lo = start.col } if idx == end.line { hi = end.col } out = append(out, strings.TrimRight(ansi.Strip(ansi.Cut(lines[idx], lo, hi)), " ")) } return strings.Join(out, "\n") } // scrollbarThumb returns the thumb's [start, start+size) row span for a viewport // of `height` rows showing `total` content lines scrolled to `yoff`. func scrollbarThumb(height, yoff, total int) (start, size int) { if total <= height { return 0, 0 // no overflow → no thumb } size = height * height / total if size < 1 { size = 1 } maxYoff := total - height start = yoff * (height - size) / maxYoff if start > height-size { start = height - size } return start, size } func scrollbarYOffset(height, row, total, grabOffset int) int { if total <= height { return 0 } _, thumbSize := scrollbarThumb(height, 0, total) maxTop := height - thumbSize if maxTop <= 0 { return 0 } top := row - grabOffset if top < 0 { top = 0 } if top > maxTop { top = maxTop } maxYoff := total - height return (top*maxYoff + maxTop/2) / maxTop } func scrollbarCell(row, total, height, thumbStart, thumbSize int) string { if total <= height { return " " } if row >= thumbStart && row < thumbStart+thumbSize { return scrollThumbStyle.Render("█") } return scrollTrackStyle.Render("│") } func (m chatTUI) inScrollbar(x, y int) bool { if m.nativeScrollback { return false } h := m.viewport.Height() return h > 0 && y >= 0 && y < h && x == m.viewport.Width() && len(m.wrappedLines) > h } func (m chatTUI) scrollbarGrabRowOffset(row int) int { thumbStart, thumbSize := scrollbarThumb(m.viewport.Height(), m.viewport.YOffset(), len(m.wrappedLines)) if row >= thumbStart && row < thumbStart+thumbSize { return row - thumbStart } return thumbSize / 2 } func (m *chatTUI) dragScrollbar(row int) { m.viewport.SetYOffset(scrollbarYOffset(m.viewport.Height(), row, len(m.wrappedLines), m.scrollbarGrabOffset)) } // transcriptCaret maps a screen cell (x, y) in the transcript region to an // absolute content position, clamping to the visible window. func (m chatTUI) transcriptCaret(x, y int) selPos { h := m.viewport.Height() if y < 0 { y = 0 } if y > h-1 { y = h - 1 } if x < 0 { x = 0 } if cw := m.viewport.Width(); x > cw { x = cw } return selPos{line: m.viewport.YOffset() + y, col: x} }