| 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 |