返回 DeepSeek-Reasonix
transcript.go
根目录 / internal / cli / transcript.go
1 package cli
2
3 import (
4 "encoding/base64"
5 "math"
6 "os"
7 "strconv"
8 "strings"
9 "time"
10
11 tea "charm.land/bubbletea/v2"
12 "charm.land/lipgloss/v2"
13 "github.com/atotto/clipboard"
14 "github.com/charmbracelet/x/ansi"
15
16 "reasonix/internal/provider"
17 )
18
19 type transcriptSourceKind uint8
20
21 const (
22 transcriptSourceFixed transcriptSourceKind = iota
23 transcriptSourceMarkdown
24 transcriptSourceUser
25 transcriptSourceReasoning
26 transcriptSourceToolCard
27 transcriptSourceBanner
28 transcriptSourceReplayBundle
29 transcriptSourceTurnReceipt
30 transcriptSourceSubagentProgress
31 )
32
33 // transcriptSource retains only the semantic inputs needed to reproduce a
34 // width-dependent transcript block. It deliberately sits beside []string
35 // instead of replacing it: the rendered slice remains the fast path for every
36 // frame and preserves the many index-based live tool/reasoning updates.
37 type transcriptSource struct {
38 kind transcriptSourceKind
39 raw string
40 aux string
41 planMode bool
42 maxLines int
43 history []provider.Message
44 }
45
46 func (m *chatTUI) ensureTranscriptSources() {
47 if len(m.transcriptSources) > len(m.transcript) {
48 m.transcriptSources = m.transcriptSources[:len(m.transcript)]
49 }
50 for len(m.transcriptSources) < len(m.transcript) {
51 m.transcriptSources = append(m.transcriptSources, transcriptSource{kind: transcriptSourceFixed})
52 }
53 }
54
55 func (m *chatTUI) appendTranscriptBlock(rendered string, source transcriptSource) {
56 m.ensureTranscriptSources()
57 m.transcript = append(m.transcript, rendered)
58 m.transcriptSources = append(m.transcriptSources, source)
59 // Wrap cache extends on next Update via append-only path.
60 }
61
62 func (m *chatTUI) setTranscriptBlock(index int, rendered string, source transcriptSource) {
63 if index < 0 || index >= len(m.transcript) {
64 return
65 }
66 m.ensureTranscriptSources()
67 m.transcript[index] = rendered
68 m.transcriptSources[index] = source
69 // In-place rewrite: drop wrap from this block onward so the next sync
70 // re-wraps the mutated block and everything after it.
71 m.invalidateWrapFrom(index)
72 }
73
74 func (m *chatTUI) removeTranscriptBlock(index int) {
75 if index < 0 || index >= len(m.transcript) {
76 return
77 }
78 m.ensureTranscriptSources()
79 m.transcript = append(m.transcript[:index], m.transcript[index+1:]...)
80 m.transcriptSources = append(m.transcriptSources[:index], m.transcriptSources[index+1:]...)
81 m.invalidateWrapFrom(index)
82 }
83
84 func (m *chatTUI) truncateTranscriptBlocks(length int) {
85 length = min(max(length, 0), len(m.transcript))
86 m.ensureTranscriptSources()
87 m.transcript = m.transcript[:length]
88 m.transcriptSources = m.transcriptSources[:length]
89 m.invalidateWrapFrom(length)
90 }
91
92 func (m *chatTUI) renderTranscriptSource(source transcriptSource, terminalWidth int) string {
93 contentWidth := transcriptContentWidth(terminalWidth, m.nativeScrollback)
94 switch source.kind {
95 case transcriptSourceMarkdown:
96 return renderAssistantMarkdown(source.raw, contentWidth)
97 case transcriptSourceUser:
98 return renderUserBubble(source.raw, terminalWidth, source.planMode)
99 case transcriptSourceReasoning:
100 return reasoningBlock(source.raw, terminalWidth, source.maxLines)
101 case transcriptSourceToolCard:
102 return toolCard(source.raw, source.aux, terminalWidth)
103 case transcriptSourceBanner:
104 return strings.TrimRight(renderTUIBanner(m.label, source.raw, contentWidth), "\n")
105 case transcriptSourceReplayBundle:
106 return m.renderReplayBundle(source, contentWidth, renderAssistantMarkdown)
107 case transcriptSourceTurnReceipt:
108 return renderTurnReceiptBand(source.raw, contentWidth)
109 case transcriptSourceSubagentProgress:
110 if sp := m.subagentProgress[source.raw]; sp != nil {
111 return m.subagentProgressBlock(source.raw, sp)
112 }
113 return ""
114 default:
115 return ""
116 }
117 }
118
119 func (m chatTUI) renderReplayBundle(
120 source transcriptSource,
121 contentWidth int,
122 renderAssistant func(string, int) string,
123 ) string {
124 var b strings.Builder
125 b.WriteString(renderTUIBanner(m.label, source.raw, contentWidth))
126 for _, section := range replaySectionsForWithAssistantRenderer(
127 source.history,
128 contentWidth,
129 renderAssistant,
130 ) {
131 b.WriteString(section)
132 }
133 return strings.TrimRight(b.String(), "\n")
134 }
135
136 func (m chatTUI) renderReplayBundleCopy(
137 source transcriptSource,
138 contentWidth int,
139 prefix string,
140 ) string {
141 assistantIndex := 0
142 return m.renderReplayBundle(source, contentWidth, func(raw string, width int) string {
143 messagePrefix := prefix + "-" + strconv.Itoa(assistantIndex)
144 assistantIndex++
145 return renderAssistantMarkdownCopy(raw, width, messagePrefix)
146 })
147 }
148
149 const assistantTranscriptIndent = " "
150
151 // renderAssistantMarkdown gives assistant prose the same explicit transcript
152 // identity that user, reasoning, tool, and receipt blocks already have. The
153 // body keeps a restrained two-cell gutter instead of using a heavy card, and
154 // rendering at the reduced width keeps every indented row inside the viewport.
155 func renderAssistantMarkdown(raw string, contentWidth int) string {
156 contentWidth = max(contentWidth, 1)
157 indent := assistantTranscriptIndent
158 if contentWidth <= visibleWidth(indent) {
159 indent = ""
160 }
161 bodyWidth := max(contentWidth-visibleWidth(indent), 1)
162 renderer := newMarkdownRenderer(bodyWidth)
163 rendered := renderer.Render(raw)
164 if rendered == "" {
165 rendered = raw
166 }
167 body := strings.TrimRight(rendered, "\n")
168 header := indent + accent("◆") + " " + bold("Reasonix")
169 if body == "" {
170 return header
171 }
172 return header + "\n\n" + indentTranscriptBlock(body, indent)
173 }
174
175 // renderAssistantMarkdownCopy mirrors renderAssistantMarkdown's visible output
176 // and adds zero-width math markers for on-demand clipboard reconstruction.
177 func renderAssistantMarkdownCopy(raw string, contentWidth int, prefix string) string {
178 contentWidth = max(contentWidth, 1)
179 indent := assistantTranscriptIndent
180 if contentWidth <= visibleWidth(indent) {
181 indent = ""
182 }
183 bodyWidth := max(contentWidth-visibleWidth(indent), 1)
184 renderer := newMarkdownRenderer(bodyWidth)
185 rendered := renderer.RenderCopy(raw, prefix)
186 if rendered == "" {
187 rendered = raw
188 }
189 body := strings.TrimRight(rendered, "\n")
190 header := indent + accent("◆") + " " + bold("Reasonix")
191 if body == "" {
192 return header
193 }
194 return header + "\n\n" + indentTranscriptBlock(body, indent)
195 }
196
197 func indentTranscriptBlock(block, indent string) string {
198 if indent == "" || block == "" {
199 return block
200 }
201 lines := strings.Split(block, "\n")
202 for i, line := range lines {
203 if line != "" {
204 lines[i] = indent + line
205 }
206 }
207 return strings.Join(lines, "\n")
208 }
209
210 func renderTurnReceiptBand(receipt string, contentWidth int) string {
211 if strings.TrimSpace(ansi.Strip(receipt)) == "" {
212 return ""
213 }
214 contentWidth = max(contentWidth, 1)
215 if contentWidth <= visibleWidth(statusFooterIndent) {
216 rule := themeFg(activeCLITheme.border, strings.Repeat("─", contentWidth))
217 return rule + "\n" + wrapTranscript(receipt, contentWidth)
218 }
219 indent := statusFooterIndent
220 innerWidth := contentWidth - visibleWidth(indent)
221 rule := indent + themeFg(activeCLITheme.border, strings.Repeat("─", innerWidth))
222 body := wrapTranscript(receipt, contentWidth)
223 return rule + "\n" + body
224 }
225
226 func (m *chatTUI) reflowTranscript(terminalWidth int) {
227 m.ensureTranscriptSources()
228 for i, source := range m.transcriptSources {
229 if source.kind == transcriptSourceFixed {
230 continue
231 }
232 m.transcript[i] = m.renderTranscriptSource(source, terminalWidth)
233 }
234 }
235
236 func (m *chatTUI) commitTranscriptSource(source transcriptSource) {
237 rendered := m.renderTranscriptSource(source, m.width)
238 *m.pendingCommit = append(*m.pendingCommit, rendered)
239 m.appendTranscriptBlock(rendered, source)
240 }
241
242 const (
243 copyMathStartPrefix = "\x1b]1337;reasonix-copy-math="
244 copyMathEndPrefix = "\x1b]1337;reasonix-copy-math-end="
245 copyMathTerminator = "\x07"
246 )
247
248 func copyMathStartMarker(id, source string) string {
249 encoded := base64.RawURLEncoding.EncodeToString([]byte(source))
250 return copyMathStartPrefix + id + ";" + encoded + copyMathTerminator
251 }
252
253 func copyMathEndMarker(id string) string {
254 return copyMathEndPrefix + id + copyMathTerminator
255 }
256
257 // buildCopyTranscript renders semantic Markdown only when a copy is requested.
258 // The visible text stays byte-for-byte equivalent after ANSI stripping, while
259 // math markers retain the source needed to map display cells back to LaTeX.
260 func (m chatTUI) buildCopyTranscript(contentWidth int) (string, int, bool) {
261 if len(m.transcriptSources) != len(m.transcript) {
262 return "", 0, false
263 }
264 var b strings.Builder
265 markers := 0
266 for i, source := range m.transcriptSources {
267 if i > 0 {
268 b.WriteByte('\n')
269 }
270 switch source.kind {
271 case transcriptSourceMarkdown:
272 rendered := renderAssistantMarkdownCopy(source.raw, contentWidth, strconv.Itoa(i))
273 markers += strings.Count(rendered, copyMathStartPrefix)
274 b.WriteString(rendered)
275 case transcriptSourceReplayBundle:
276 rendered := m.renderReplayBundleCopy(source, contentWidth, strconv.Itoa(i))
277 markers += strings.Count(rendered, copyMathStartPrefix)
278 b.WriteString(rendered)
279 default:
280 b.WriteString(m.transcript[i])
281 }
282 }
283 return b.String(), markers, true
284 }
285
286 // transcriptResizeAnchor identifies the transcript block at the top of the
287 // viewport plus the relative row within it. Reflow can change a block's line
288 // count, so preserving a raw Y offset would jump to unrelated content.
289 type transcriptResizeAnchor struct {
290 block int
291 fraction float64
292 valid bool
293 }
294
295 func captureTranscriptResizeAnchor(blocks []string, width, yOffset int) transcriptResizeAnchor {
296 if width <= 0 || len(blocks) == 0 {
297 return transcriptResizeAnchor{}
298 }
299 remaining := max(yOffset, 0)
300 for i, block := range blocks {
301 lines := transcriptBlockLineCount(block, width)
302 if remaining < lines {
303 fraction := 0.0
304 if lines > 1 {
305 fraction = float64(remaining) / float64(lines-1)
306 }
307 return transcriptResizeAnchor{block: i, fraction: fraction, valid: true}
308 }
309 remaining -= lines
310 }
311 return transcriptResizeAnchor{block: len(blocks) - 1, fraction: 1, valid: true}
312 }
313
314 func (a transcriptResizeAnchor) yOffset(blocks []string, width int) int {
315 if !a.valid || len(blocks) == 0 || width <= 0 {
316 return 0
317 }
318 block := min(max(a.block, 0), len(blocks)-1)
319 offset := 0
320 for i := 0; i < block; i++ {
321 offset += transcriptBlockLineCount(blocks[i], width)
322 }
323 lines := transcriptBlockLineCount(blocks[block], width)
324 if lines > 1 {
325 offset += int(math.Round(a.fraction * float64(lines-1)))
326 }
327 return offset
328 }
329
330 func transcriptBlockLineCount(block string, width int) int {
331 return strings.Count(wrapTranscript(block, width), "\n") + 1
332 }
333
334 // wrapTranscript wraps the joined transcript to width for the viewport, keeping
335 // SGR balanced across wrap points. ansi.Hardwrap leaves a style that spans a
336 // break open at the line end (e.g. a wrapped dim link tail), which bleeds the
337 // attribute into the padding and the next row on stricter terminals (Warp).
338 // lipgloss closes the active style at each line end and reopens it at the next.
339 func wrapTranscript(s string, width int) string {
340 if width <= 0 {
341 return s
342 }
343 return lipgloss.NewStyle().Width(width).Render(s)
344 }
345
346 type clipboardCopyMsg struct {
347 text string
348 err error
349 osc52 bool
350 statusHint bool
351 seq int
352 }
353
354 var writeNativeClipboardText = clipboard.WriteAll
355
356 func remoteClipboardSession() bool {
357 return os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_CLIENT") != "" || os.Getenv("SSH_TTY") != ""
358 }
359
360 // copyToClipboard prefers the operating system clipboard in a local session,
361 // where success can be verified (pbcopy on macOS, the selected Wayland/X11
362 // utility on Linux, and the Win32 clipboard on Windows). SSH cannot reliably
363 // reach the user's local desktop clipboard, so it deliberately falls back to
364 // OSC 52. A failed local write also falls back, but the UI labels that path as
365 // an unverified terminal request rather than claiming a successful copy.
366 func copyToClipboard(text string) tea.Cmd {
367 return copyToClipboardWithStatus(text, 0, false)
368 }
369
370 func copyToClipboardWithStatus(text string, seq int, statusHint bool) tea.Cmd {
371 return func() tea.Msg {
372 if remoteClipboardSession() {
373 return clipboardCopyMsg{text: text, osc52: true, statusHint: statusHint, seq: seq}
374 }
375 return clipboardCopyMsg{
376 text: text,
377 err: writeNativeClipboardText(text),
378 statusHint: statusHint,
379 seq: seq,
380 }
381 }
382 }
383
384 // copyNoticeTTL is how long the "copied to clipboard" status-line hint stays
385 // visible after a selection copy (mouse drag, right-click, or Ctrl+C) before
386 // copyNoticeExpireMsg clears it.
387 const copyNoticeTTL = 1500 * time.Millisecond
388
389 // copyNoticeExpireMsg clears the transient copy notice — but only if seq still
390 // matches m.copyNoticeSeq, so an older copy's timer can't stomp a newer notice
391 // (e.g. drag-copy immediately followed by a right-click re-copy).
392 type copyNoticeExpireMsg struct{ seq int }
393
394 // copySelectionWithNotice copies text to the clipboard and arms the status-line
395 // "copied to clipboard" hint, bumping copyNoticeSeq so any in-flight expiry tick
396 // from a prior copy is superseded rather than racing this one.
397 func (m *chatTUI) copySelectionWithNotice(text string) tea.Cmd {
398 m.copyNoticeSeq++
399 seq := m.copyNoticeSeq
400 return copyToClipboardWithStatus(text, seq, true)
401 }
402
403 func copyNoticeExpire(seq int) tea.Cmd {
404 return tea.Tick(copyNoticeTTL, func(time.Time) tea.Msg {
405 return copyNoticeExpireMsg{seq: seq}
406 })
407 }
408
409 // autoScrollMsg drives one step of edge-drag scrolling while a selection is held
410 // against the top or bottom of the transcript.
411 type autoScrollMsg struct{}
412
413 func autoScrollTick() tea.Cmd {
414 return tea.Tick(80*time.Millisecond, func(time.Time) tea.Msg { return autoScrollMsg{} })
415 }
416
417 // edgeScrollDir reports the auto-scroll direction for a drag at screen row y in
418 // a viewport of `height` rows: -1 at the top edge, +1 at the bottom, 0 between.
419 func edgeScrollDir(y, height int) int {
420 switch {
421 case y <= 0:
422 return -1
423 case y >= height-1:
424 return 1
425 default:
426 return 0
427 }
428 }
429
430 // selPos is a caret position in the wrapped transcript: a content-line index
431 // (absolute, scroll-independent) and a visual column.
432 type selPos struct{ line, col int }
433
434 // selection is the live left-drag text selection over the transcript. anchor is
435 // where the drag began, head where it currently is; active gates rendering and
436 // copy. Coordinates are absolute content lines so scrolling never moves them.
437 type selection struct {
438 active bool
439 anchor, head selPos
440 }
441
442 func (s selection) ordered() (start, end selPos) {
443 if s.anchor.line > s.head.line || (s.anchor.line == s.head.line && s.anchor.col > s.head.col) {
444 return s.head, s.anchor
445 }
446 return s.anchor, s.head
447 }
448
449 func (s selection) empty() bool { return s.anchor == s.head }
450
451 var (
452 selStyle = lipgloss.NewStyle().Reverse(true)
453 scrollThumbStyle lipgloss.Style
454 scrollTrackStyle lipgloss.Style
455 )
456
457 // renderTranscript draws the viewport's visible window with a scrollbar in the
458 // last column and the active selection reverse-highlighted. The content lines
459 // (m.wrappedLines) are already padded to cw by wrapTranscript, so this stays
460 // cheap per frame — important because a drag re-renders on every mouse move.
461 func (m chatTUI) renderTranscript() string {
462 h := m.viewport.Height()
463 if h <= 0 {
464 return ""
465 }
466 cw := m.viewport.Width() // content width; the scrollbar occupies one more column
467 lines := m.wrappedLines
468 total := len(lines)
469 yoff := m.viewport.YOffset()
470 start, end := m.sel.ordered()
471 thumbStart, thumbSize := scrollbarThumb(h, yoff, total)
472 blank := strings.Repeat(" ", cw)
473
474 rows := make([]string, h)
475 bar := make([]string, h)
476 for r := 0; r < h; r++ {
477 idx := yoff + r
478 line := blank // off-content rows fill to width
479 if idx >= 0 && idx < total {
480 line = lines[idx] // already cw-wide from wrapTranscript
481 }
482 if m.sel.active && !m.sel.empty() {
483 if lo, hi, ok := selSpan(idx, start, end, cw); ok {
484 line = lipgloss.StyleRanges(line, lipgloss.NewRange(lo, hi, selStyle))
485 }
486 }
487 rows[r] = line
488 bar[r] = scrollbarCell(r, total, h, thumbStart, thumbSize)
489 }
490 return lipgloss.JoinHorizontal(lipgloss.Top, strings.Join(rows, "\n"), strings.Join(bar, "\n"))
491 }
492
493 // selSpan returns the [lo, hi) visual-column span of the selection on content
494 // line idx (false when the line is outside the selection). cw bounds the span
495 // so a multi-line selection highlights through the right edge.
496 func selSpan(idx int, start, end selPos, cw int) (lo, hi int, ok bool) {
497 if idx < start.line || idx > end.line {
498 return 0, 0, false
499 }
500 lo, hi = 0, cw
501 if idx == start.line {
502 lo = start.col
503 }
504 if idx == end.line {
505 hi = end.col
506 }
507 if hi > cw {
508 hi = cw
509 }
510 if lo >= hi {
511 return 0, 0, false
512 }
513 return lo, hi, true
514 }
515
516 type copyMathSpan struct {
517 start int
518 end int
519 id string
520 source string
521 }
522
523 type copyTranscriptLine struct {
524 text string
525 math []copyMathSpan
526 }
527
528 type activeCopyMath struct {
529 id string
530 source string
531 start int
532 }
533
534 func parseCopyTranscript(wrapped string) ([]copyTranscriptLine, int, bool) {
535 rawLines := strings.Split(wrapped, "\n")
536 lines := make([]copyTranscriptLine, 0, len(rawLines))
537 var active *activeCopyMath
538 parsedMarkers := 0
539
540 for _, raw := range rawLines {
541 var clean strings.Builder
542 var spans []copyMathSpan
543 column := 0
544 position := 0
545
546 for position < len(raw) {
547 startAt := strings.Index(raw[position:], copyMathStartPrefix)
548 endAt := strings.Index(raw[position:], copyMathEndPrefix)
549 if startAt >= 0 {
550 startAt += position
551 }
552 if endAt >= 0 {
553 endAt += position
554 }
555
556 markerAt := -1
557 isStart := false
558 switch {
559 case startAt >= 0 && (endAt < 0 || startAt < endAt):
560 markerAt, isStart = startAt, true
561 case endAt >= 0:
562 markerAt = endAt
563 }
564 if markerAt < 0 {
565 chunk := raw[position:]
566 clean.WriteString(chunk)
567 column += ansi.StringWidth(chunk)
568 break
569 }
570
571 chunk := raw[position:markerAt]
572 clean.WriteString(chunk)
573 column += ansi.StringWidth(chunk)
574
575 prefix := copyMathEndPrefix
576 if isStart {
577 prefix = copyMathStartPrefix
578 }
579 payloadStart := markerAt + len(prefix)
580 terminatorAt := strings.Index(raw[payloadStart:], copyMathTerminator)
581 if terminatorAt < 0 {
582 return nil, 0, false
583 }
584 terminatorAt += payloadStart
585 payload := raw[payloadStart:terminatorAt]
586 position = terminatorAt + len(copyMathTerminator)
587
588 if isStart {
589 parts := strings.SplitN(payload, ";", 2)
590 if len(parts) != 2 || active != nil {
591 return nil, 0, false
592 }
593 decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
594 if err != nil {
595 return nil, 0, false
596 }
597 active = &activeCopyMath{id: parts[0], source: string(decoded), start: column}
598 parsedMarkers++
599 continue
600 }
601
602 if active == nil || active.id != payload {
603 return nil, 0, false
604 }
605 spans = append(spans, copyMathSpan{
606 start: active.start, end: column, id: active.id, source: active.source,
607 })
608 active = nil
609 }
610
611 if active != nil {
612 spans = append(spans, copyMathSpan{
613 start: active.start, end: column, id: active.id, source: active.source,
614 })
615 active.start = 0
616 }
617 lines = append(lines, copyTranscriptLine{text: clean.String(), math: spans})
618 }
619 if active != nil {
620 return nil, 0, false
621 }
622 return lines, parsedMarkers, true
623 }
624
625 func (m chatTUI) copyTranscriptLines() ([]copyTranscriptLine, bool) {
626 contentWidth := m.viewport.Width()
627 marked, expectedMarkers, ok := m.buildCopyTranscript(contentWidth)
628 if !ok {
629 return nil, false
630 }
631 lines, parsedMarkers, ok := parseCopyTranscript(wrapTranscript(marked, contentWidth))
632 if !ok || parsedMarkers != expectedMarkers || len(lines) != len(m.wrappedLines) {
633 return nil, false
634 }
635 for i := range lines {
636 if ansi.Strip(lines[i].text) != ansi.Strip(m.wrappedLines[i]) {
637 return nil, false
638 }
639 }
640 return lines, true
641 }
642
643 func selectedDisplayText(lines []string, start, end selPos) string {
644 var out []string
645 for idx := start.line; idx <= end.line && idx < len(lines); idx++ {
646 lo, hi := 0, ansi.StringWidth(lines[idx])
647 if idx == start.line {
648 lo = start.col
649 }
650 if idx == end.line {
651 hi = end.col
652 }
653 out = append(out, strings.TrimRight(ansi.Strip(ansi.Cut(lines[idx], lo, hi)), " "))
654 }
655 return strings.Join(out, "\n")
656 }
657
658 func selectedCopyText(lines []copyTranscriptLine, start, end selPos) string {
659 seen := make(map[string]bool)
660 var out []string
661 for idx := start.line; idx <= end.line && idx < len(lines); idx++ {
662 line := lines[idx]
663 lo, hi := 0, ansi.StringWidth(line.text)
664 if idx == start.line {
665 lo = start.col
666 }
667 if idx == end.line {
668 hi = end.col
669 }
670
671 var selected strings.Builder
672 cursor := lo
673 touchedMath := false
674 for _, span := range line.math {
675 if span.end <= lo || span.start >= hi {
676 continue
677 }
678 touchedMath = true
679 if span.start > cursor {
680 selected.WriteString(ansi.Strip(ansi.Cut(line.text, cursor, min(span.start, hi))))
681 }
682 if !seen[span.id] {
683 selected.WriteString(span.source)
684 seen[span.id] = true
685 }
686 cursor = max(cursor, min(span.end, hi))
687 }
688 if cursor < hi {
689 selected.WriteString(ansi.Strip(ansi.Cut(line.text, cursor, hi)))
690 }
691 if selected.Len() == 0 && touchedMath {
692 continue
693 }
694 out = append(out, strings.TrimRight(selected.String(), " "))
695 }
696 return strings.Join(out, "\n")
697 }
698
699 // selectedText is the plain text of the active display-cell selection. Math is
700 // reconstructed on demand from semantic transcript sources; if the marked copy
701 // rendition ever diverges from the visible transcript, the safe fallback keeps
702 // the exact displayed text rather than applying mismatched coordinates.
703 func (m chatTUI) selectedText() string {
704 if !m.sel.active || m.sel.empty() {
705 return ""
706 }
707 start, end := m.sel.ordered()
708 if lines, ok := m.copyTranscriptLines(); ok {
709 return selectedCopyText(lines, start, end)
710 }
711 return selectedDisplayText(m.wrappedLines, start, end)
712 }
713
714 // scrollbarThumb returns the thumb's [start, start+size) row span for a viewport
715 // of `height` rows showing `total` content lines scrolled to `yoff`.
716 func scrollbarThumb(height, yoff, total int) (start, size int) {
717 if total <= height {
718 return 0, 0 // no overflow → no thumb
719 }
720 size = height * height / total
721 if size < 1 {
722 size = 1
723 }
724 maxYoff := total - height
725 start = yoff * (height - size) / maxYoff
726 if start > height-size {
727 start = height - size
728 }
729 return start, size
730 }
731
732 func scrollbarYOffset(height, row, total, grabOffset int) int {
733 if total <= height {
734 return 0
735 }
736 _, thumbSize := scrollbarThumb(height, 0, total)
737 maxTop := height - thumbSize
738 if maxTop <= 0 {
739 return 0
740 }
741 top := row - grabOffset
742 if top < 0 {
743 top = 0
744 }
745 if top > maxTop {
746 top = maxTop
747 }
748 maxYoff := total - height
749 return (top*maxYoff + maxTop/2) / maxTop
750 }
751
752 func scrollbarCell(row, total, height, thumbStart, thumbSize int) string {
753 if total <= height {
754 return " "
755 }
756 if row >= thumbStart && row < thumbStart+thumbSize {
757 return scrollThumbStyle.Render("█")
758 }
759 return scrollTrackStyle.Render("│")
760 }
761
762 func (m chatTUI) inScrollbar(x, y int) bool {
763 if m.nativeScrollback {
764 return false
765 }
766 h := m.viewport.Height()
767 return h > 0 && y >= 0 && y < h && x == m.viewport.Width() && len(m.wrappedLines) > h
768 }
769
770 func (m chatTUI) scrollbarGrabRowOffset(row int) int {
771 thumbStart, thumbSize := scrollbarThumb(m.viewport.Height(), m.viewport.YOffset(), len(m.wrappedLines))
772 if row >= thumbStart && row < thumbStart+thumbSize {
773 return row - thumbStart
774 }
775 return thumbSize / 2
776 }
777
778 func (m *chatTUI) dragScrollbar(row int) {
779 m.viewport.SetYOffset(scrollbarYOffset(m.viewport.Height(), row, len(m.wrappedLines), m.scrollbarGrabOffset))
780 // Sync immediately so a streaming event between drag motions cannot see a
781 // stale followTail and yank the reader back to the bottom (#6430/#6978).
782 m.syncScrollModeAfterGesture()
783 }
784
785 // transcriptCaret maps a screen cell (x, y) in the transcript region to an
786 // absolute content position, clamping to the visible window.
787 func (m chatTUI) transcriptCaret(x, y int) selPos {
788 h := m.viewport.Height()
789 if y < 0 {
790 y = 0
791 }
792 if y > h-1 {
793 y = h - 1
794 }
795 if x < 0 {
796 x = 0
797 }
798 if cw := m.viewport.Width(); x > cw {
799 x = cw
800 }
801 return selPos{line: m.viewport.YOffset() + y, col: x}
802 }
803
803 lines GO