| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "math" |
| 5 | "os" |
| 6 | "strconv" |
| 7 | "strings" |
| 8 | "time" |
| 9 | |
| 10 | tea "charm.land/bubbletea/v2" |
| 11 | "charm.land/lipgloss/v2" |
| 12 | "github.com/charmbracelet/x/ansi" |
| 13 | |
| 14 | "reasonix/internal/provider" |
| 15 | ) |
| 16 | |
| 17 | type transcriptSourceKind uint8 |
| 18 | |
| 19 | const ( |
| 20 | transcriptSourceFixed transcriptSourceKind = iota |
| 21 | transcriptSourceMarkdown |
| 22 | transcriptSourceUser |
| 23 | transcriptSourceReasoning |
| 24 | transcriptSourceToolCard |
| 25 | transcriptSourceBanner |
| 26 | transcriptSourceReplayBundle |
| 27 | transcriptSourceTurnReceipt |
| 28 | transcriptSourceSubagentProgress |
| 29 | ) |
| 30 | |
| 31 | // transcriptSource retains only the semantic inputs needed to reproduce a |
| 32 | // width-dependent transcript block. It deliberately sits beside []string |
| 33 | // instead of replacing it: the rendered slice remains the fast path for every |
| 34 | // frame and preserves the many index-based live tool/reasoning updates. |
| 35 | type transcriptSource struct { |
| 36 | kind transcriptSourceKind |
| 37 | raw string |
| 38 | aux string |
| 39 | // copyRendered mirrors an already-rendered fixed block with internal copy |
| 40 | // spans. It is never displayed or persisted; selection copy consumes the |
| 41 | // spans to omit decorations whose provenance would otherwise be ambiguous. |
| 42 | copyRendered string |
| 43 | planMode bool |
| 44 | maxLines int |
| 45 | history []provider.Message |
| 46 | } |
| 47 | |
| 48 | func (m *chatTUI) ensureTranscriptSources() { |
| 49 | if len(m.transcriptSources) > len(m.transcript) { |
| 50 | m.transcriptSources = m.transcriptSources[:len(m.transcript)] |
| 51 | } |
| 52 | for len(m.transcriptSources) < len(m.transcript) { |
| 53 | m.transcriptSources = append(m.transcriptSources, transcriptSource{kind: transcriptSourceFixed}) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | func (m *chatTUI) appendTranscriptBlock(rendered string, source transcriptSource) { |
| 58 | m.ensureTranscriptSources() |
| 59 | m.transcript = append(m.transcript, rendered) |
| 60 | m.transcriptSources = append(m.transcriptSources, source) |
| 61 | // Wrap cache extends on next Update via append-only path. |
| 62 | } |
| 63 | |
| 64 | func (m *chatTUI) setTranscriptBlock(index int, rendered string, source transcriptSource) { |
| 65 | if index < 0 || index >= len(m.transcript) { |
| 66 | return |
| 67 | } |
| 68 | m.ensureTranscriptSources() |
| 69 | m.transcript[index] = rendered |
| 70 | m.transcriptSources[index] = source |
| 71 | // In-place rewrite: drop wrap from this block onward so the next sync |
| 72 | // re-wraps the mutated block and everything after it. |
| 73 | m.invalidateWrapFrom(index) |
| 74 | } |
| 75 | |
| 76 | func (m *chatTUI) removeTranscriptBlock(index int) { |
| 77 | if index < 0 || index >= len(m.transcript) { |
| 78 | return |
| 79 | } |
| 80 | m.ensureTranscriptSources() |
| 81 | m.transcript = append(m.transcript[:index], m.transcript[index+1:]...) |
| 82 | m.transcriptSources = append(m.transcriptSources[:index], m.transcriptSources[index+1:]...) |
| 83 | m.invalidateWrapFrom(index) |
| 84 | } |
| 85 | |
| 86 | func (m *chatTUI) truncateTranscriptBlocks(length int) { |
| 87 | length = min(max(length, 0), len(m.transcript)) |
| 88 | m.ensureTranscriptSources() |
| 89 | m.transcript = m.transcript[:length] |
| 90 | m.transcriptSources = m.transcriptSources[:length] |
| 91 | m.invalidateWrapFrom(length) |
| 92 | } |
| 93 | |
| 94 | func (m *chatTUI) renderTranscriptSource(source transcriptSource, terminalWidth int) string { |
| 95 | contentWidth := transcriptContentWidth(terminalWidth, m.nativeScrollback) |
| 96 | switch source.kind { |
| 97 | case transcriptSourceMarkdown: |
| 98 | return renderAssistantMarkdown(source.raw, contentWidth) |
| 99 | case transcriptSourceUser: |
| 100 | return renderUserBubble(source.raw, terminalWidth, source.planMode) |
| 101 | case transcriptSourceReasoning: |
| 102 | return reasoningBlock(source.raw, terminalWidth, source.maxLines) |
| 103 | case transcriptSourceToolCard: |
| 104 | return toolCard(source.raw, source.aux, terminalWidth) |
| 105 | case transcriptSourceBanner: |
| 106 | return strings.TrimRight(renderTUIBanner(m.label, source.raw, contentWidth), "\n") |
| 107 | case transcriptSourceReplayBundle: |
| 108 | return m.renderReplayBundle(source, contentWidth, renderAssistantMarkdown) |
| 109 | case transcriptSourceTurnReceipt: |
| 110 | return renderTurnReceiptBand(source.raw, contentWidth) |
| 111 | case transcriptSourceSubagentProgress: |
| 112 | if sp := m.subagentProgress[source.raw]; sp != nil { |
| 113 | return m.subagentProgressBlock(source.raw, sp) |
| 114 | } |
| 115 | return "" |
| 116 | default: |
| 117 | return "" |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | func (m chatTUI) renderReplayBundle( |
| 122 | source transcriptSource, |
| 123 | contentWidth int, |
| 124 | renderAssistant func(string, int) string, |
| 125 | ) string { |
| 126 | return m.renderReplayBundleWithRenderers(source, contentWidth, renderAssistant, reasoningBlock) |
| 127 | } |
| 128 | |
| 129 | func (m chatTUI) renderReplayBundleWithRenderers( |
| 130 | source transcriptSource, |
| 131 | contentWidth int, |
| 132 | renderAssistant func(string, int) string, |
| 133 | renderReasoning func(string, int, int) string, |
| 134 | ) string { |
| 135 | var b strings.Builder |
| 136 | b.WriteString(renderTUIBanner(m.label, source.raw, contentWidth)) |
| 137 | for _, section := range replaySectionsForWithRenderers( |
| 138 | source.history, |
| 139 | contentWidth, |
| 140 | renderAssistant, |
| 141 | renderReasoning, |
| 142 | ) { |
| 143 | b.WriteString(section) |
| 144 | } |
| 145 | return strings.TrimRight(b.String(), "\n") |
| 146 | } |
| 147 | |
| 148 | func (m chatTUI) renderReplayBundleCopy( |
| 149 | source transcriptSource, |
| 150 | contentWidth int, |
| 151 | prefix string, |
| 152 | ) string { |
| 153 | assistantIndex := 0 |
| 154 | return m.renderReplayBundleWithRenderers(source, contentWidth, func(raw string, width int) string { |
| 155 | messagePrefix := prefix + "-" + strconv.Itoa(assistantIndex) |
| 156 | assistantIndex++ |
| 157 | return renderAssistantMarkdownCopy(raw, width, messagePrefix) |
| 158 | }, reasoningBlockCopy) |
| 159 | } |
| 160 | |
| 161 | const assistantTranscriptIndent = " " |
| 162 | |
| 163 | // renderAssistantMarkdown gives assistant prose the same explicit transcript |
| 164 | // identity that user, reasoning, tool, and receipt blocks already have. The |
| 165 | // body keeps a restrained two-cell gutter instead of using a heavy card, and |
| 166 | // rendering at the reduced width keeps every indented row inside the viewport. |
| 167 | func renderAssistantMarkdown(raw string, contentWidth int) string { |
| 168 | contentWidth = max(contentWidth, 1) |
| 169 | indent := assistantTranscriptIndent |
| 170 | if contentWidth <= visibleWidth(indent) { |
| 171 | indent = "" |
| 172 | } |
| 173 | bodyWidth := max(contentWidth-visibleWidth(indent), 1) |
| 174 | renderer := newMarkdownRenderer(bodyWidth) |
| 175 | rendered := renderer.Render(raw) |
| 176 | if rendered == "" { |
| 177 | rendered = raw |
| 178 | } |
| 179 | body := strings.TrimRight(rendered, "\n") |
| 180 | header := indent + accent("◆") + " " + bold("Reasonix") |
| 181 | if body == "" { |
| 182 | return header |
| 183 | } |
| 184 | return header + "\n\n" + indentTranscriptBlock(body, indent) |
| 185 | } |
| 186 | |
| 187 | // renderAssistantMarkdownCopy mirrors renderAssistantMarkdown's visible output |
| 188 | // and adds zero-width copy spans for math reconstruction and generated gutters. |
| 189 | func renderAssistantMarkdownCopy(raw string, contentWidth int, prefix string) string { |
| 190 | contentWidth = max(contentWidth, 1) |
| 191 | indent := assistantTranscriptIndent |
| 192 | if contentWidth <= visibleWidth(indent) { |
| 193 | indent = "" |
| 194 | } |
| 195 | bodyWidth := max(contentWidth-visibleWidth(indent), 1) |
| 196 | renderer := newMarkdownRenderer(bodyWidth) |
| 197 | rendered := renderer.RenderCopy(raw, prefix) |
| 198 | if rendered == "" { |
| 199 | rendered = raw |
| 200 | } |
| 201 | body := strings.TrimRight(rendered, "\n") |
| 202 | header := indent + accent("◆") + " " + bold("Reasonix") |
| 203 | if body == "" { |
| 204 | return header |
| 205 | } |
| 206 | return header + "\n\n" + indentTranscriptBlock(body, indent) |
| 207 | } |
| 208 | |
| 209 | func indentTranscriptBlock(block, indent string) string { |
| 210 | if indent == "" || block == "" { |
| 211 | return block |
| 212 | } |
| 213 | lines := strings.Split(block, "\n") |
| 214 | for i, line := range lines { |
| 215 | if line != "" { |
| 216 | if rest, ok := strings.CutPrefix(line, copyOmitSpanStart); ok { |
| 217 | lines[i] = copyOmitSpanStart + indent + rest |
| 218 | } else { |
| 219 | lines[i] = indent + line |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | return strings.Join(lines, "\n") |
| 224 | } |
| 225 | |
| 226 | func renderTurnReceiptBand(receipt string, contentWidth int) string { |
| 227 | if strings.TrimSpace(ansi.Strip(receipt)) == "" { |
| 228 | return "" |
| 229 | } |
| 230 | contentWidth = max(contentWidth, 1) |
| 231 | if contentWidth <= visibleWidth(statusFooterIndent) { |
| 232 | rule := themeFg(activeCLITheme.border, strings.Repeat("─", contentWidth)) |
| 233 | return rule + "\n" + wrapTranscript(receipt, contentWidth) |
| 234 | } |
| 235 | indent := statusFooterIndent |
| 236 | innerWidth := contentWidth - visibleWidth(indent) |
| 237 | rule := indent + themeFg(activeCLITheme.border, strings.Repeat("─", innerWidth)) |
| 238 | body := wrapTranscript(receipt, contentWidth) |
| 239 | return rule + "\n" + body |
| 240 | } |
| 241 | |
| 242 | func (m *chatTUI) reflowTranscript(terminalWidth int) { |
| 243 | m.ensureTranscriptSources() |
| 244 | for i, source := range m.transcriptSources { |
| 245 | if source.kind == transcriptSourceFixed { |
| 246 | continue |
| 247 | } |
| 248 | m.transcript[i] = m.renderTranscriptSource(source, terminalWidth) |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | func (m *chatTUI) commitTranscriptSource(source transcriptSource) { |
| 253 | rendered := m.renderTranscriptSource(source, m.width) |
| 254 | *m.pendingCommit = append(*m.pendingCommit, rendered) |
| 255 | m.appendTranscriptBlock(rendered, source) |
| 256 | } |
| 257 | |
| 258 | // transcriptResizeAnchor identifies the transcript block at the top of the |
| 259 | // viewport plus the relative row within it. Reflow can change a block's line |
| 260 | // count, so preserving a raw Y offset would jump to unrelated content. |
| 261 | type transcriptResizeAnchor struct { |
| 262 | block int |
| 263 | fraction float64 |
| 264 | valid bool |
| 265 | } |
| 266 | |
| 267 | func captureTranscriptResizeAnchor(blocks []string, width, yOffset int) transcriptResizeAnchor { |
| 268 | if width <= 0 || len(blocks) == 0 { |
| 269 | return transcriptResizeAnchor{} |
| 270 | } |
| 271 | remaining := max(yOffset, 0) |
| 272 | for i, block := range blocks { |
| 273 | lines := transcriptBlockLineCount(block, width) |
| 274 | if remaining < lines { |
| 275 | fraction := 0.0 |
| 276 | if lines > 1 { |
| 277 | fraction = float64(remaining) / float64(lines-1) |
| 278 | } |
| 279 | return transcriptResizeAnchor{block: i, fraction: fraction, valid: true} |
| 280 | } |
| 281 | remaining -= lines |
| 282 | } |
| 283 | return transcriptResizeAnchor{block: len(blocks) - 1, fraction: 1, valid: true} |
| 284 | } |
| 285 | |
| 286 | func (a transcriptResizeAnchor) yOffset(blocks []string, width int) int { |
| 287 | if !a.valid || len(blocks) == 0 || width <= 0 { |
| 288 | return 0 |
| 289 | } |
| 290 | block := min(max(a.block, 0), len(blocks)-1) |
| 291 | offset := 0 |
| 292 | for i := range block { |
| 293 | offset += transcriptBlockLineCount(blocks[i], width) |
| 294 | } |
| 295 | lines := transcriptBlockLineCount(blocks[block], width) |
| 296 | if lines > 1 { |
| 297 | offset += int(math.Round(a.fraction * float64(lines-1))) |
| 298 | } |
| 299 | return offset |
| 300 | } |
| 301 | |
| 302 | func transcriptBlockLineCount(block string, width int) int { |
| 303 | return strings.Count(wrapTranscript(block, width), "\n") + 1 |
| 304 | } |
| 305 | |
| 306 | // wrapTranscript wraps the joined transcript to width for the viewport, keeping |
| 307 | // SGR balanced across wrap points. ansi.Hardwrap leaves a style that spans a |
| 308 | // break open at the line end (e.g. a wrapped dim link tail), which bleeds the |
| 309 | // attribute into the padding and the next row on stricter terminals (Warp). |
| 310 | // lipgloss closes the active style at each line end and reopens it at the next. |
| 311 | func wrapTranscript(s string, width int) string { |
| 312 | if width <= 0 { |
| 313 | return s |
| 314 | } |
| 315 | return lipgloss.NewStyle().Width(width).Render(s) |
| 316 | } |
| 317 | |
| 318 | type clipboardCopyMsg struct { |
| 319 | text string |
| 320 | err error |
| 321 | osc52 bool |
| 322 | statusHint bool |
| 323 | seq int |
| 324 | } |
| 325 | |
| 326 | var writeNativeClipboardText = writeClipboardText |
| 327 | |
| 328 | func remoteClipboardSession() bool { |
| 329 | return os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_CLIENT") != "" || os.Getenv("SSH_TTY") != "" |
| 330 | } |
| 331 | |
| 332 | // copyToClipboard prefers the operating system clipboard in a local session, |
| 333 | // where success can be verified (pbcopy on macOS, the selected Wayland/X11 |
| 334 | // utility on Linux, and the Win32 clipboard on Windows). SSH cannot reliably |
| 335 | // reach the user's local desktop clipboard, so it deliberately falls back to |
| 336 | // OSC 52. A failed local write also falls back, but the UI labels that path as |
| 337 | // an unverified terminal request rather than claiming a successful copy. |
| 338 | func copyToClipboard(text string) tea.Cmd { |
| 339 | return copyToClipboardWithStatus(text, 0, false) |
| 340 | } |
| 341 | |
| 342 | func copyToClipboardWithStatus(text string, seq int, statusHint bool) tea.Cmd { |
| 343 | return func() tea.Msg { |
| 344 | if remoteClipboardSession() { |
| 345 | return clipboardCopyMsg{text: text, osc52: true, statusHint: statusHint, seq: seq} |
| 346 | } |
| 347 | return clipboardCopyMsg{ |
| 348 | text: text, |
| 349 | err: writeNativeClipboardText(text), |
| 350 | statusHint: statusHint, |
| 351 | seq: seq, |
| 352 | } |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | // copyNoticeTTL is how long the "copied to clipboard" status-line hint stays |
| 357 | // visible after a selection copy (mouse drag, right-click, or Ctrl+C) before |
| 358 | // copyNoticeExpireMsg clears it. |
| 359 | const copyNoticeTTL = 1500 * time.Millisecond |
| 360 | |
| 361 | // copyNoticeExpireMsg clears the transient copy notice — but only if seq still |
| 362 | // matches m.copyNoticeSeq, so an older copy's timer can't stomp a newer notice |
| 363 | // (e.g. drag-copy immediately followed by a right-click re-copy). |
| 364 | type copyNoticeExpireMsg struct{ seq int } |
| 365 | |
| 366 | // copySelectionWithNotice copies text to the clipboard and arms the status-line |
| 367 | // "copied to clipboard" hint, bumping copyNoticeSeq so any in-flight expiry tick |
| 368 | // from a prior copy is superseded rather than racing this one. |
| 369 | func (m *chatTUI) copySelectionWithNotice(text string) tea.Cmd { |
| 370 | m.copyNoticeSeq++ |
| 371 | seq := m.copyNoticeSeq |
| 372 | return copyToClipboardWithStatus(text, seq, true) |
| 373 | } |
| 374 | |
| 375 | func copyNoticeExpire(seq int) tea.Cmd { |
| 376 | return tea.Tick(copyNoticeTTL, func(time.Time) tea.Msg { |
| 377 | return copyNoticeExpireMsg{seq: seq} |
| 378 | }) |
| 379 | } |
| 380 | |
| 381 | // autoScrollMsg drives one step of edge-drag scrolling while a selection is held |
| 382 | // against the top or bottom of the transcript. |
| 383 | type autoScrollMsg struct{} |
| 384 | |
| 385 | func autoScrollTick() tea.Cmd { |
| 386 | return tea.Tick(80*time.Millisecond, func(time.Time) tea.Msg { return autoScrollMsg{} }) |
| 387 | } |
| 388 | |
| 389 | // edgeScrollDir reports the auto-scroll direction for a drag at screen row y in |
| 390 | // a viewport of `height` rows: -1 at the top edge, +1 at the bottom, 0 between. |
| 391 | func edgeScrollDir(y, height int) int { |
| 392 | switch { |
| 393 | case y <= 0: |
| 394 | return -1 |
| 395 | case y >= height-1: |
| 396 | return 1 |
| 397 | default: |
| 398 | return 0 |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | // selPos is a caret position in the wrapped transcript: a content-line index |
| 403 | // (absolute, scroll-independent) and a visual column. |
| 404 | type selPos struct{ line, col int } |
| 405 | |
| 406 | // selection is the live left-drag text selection over the transcript. anchor is |
| 407 | // where the drag began, head where it currently is; active gates rendering and |
| 408 | // copy. Coordinates are absolute content lines so scrolling never moves them. |
| 409 | type selection struct { |
| 410 | active bool |
| 411 | anchor, head selPos |
| 412 | } |
| 413 | |
| 414 | func (s selection) ordered() (start, end selPos) { |
| 415 | if s.anchor.line > s.head.line || (s.anchor.line == s.head.line && s.anchor.col > s.head.col) { |
| 416 | return s.head, s.anchor |
| 417 | } |
| 418 | return s.anchor, s.head |
| 419 | } |
| 420 | |
| 421 | func (s selection) empty() bool { return s.anchor == s.head } |
| 422 | |
| 423 | var ( |
| 424 | selStyle = lipgloss.NewStyle().Reverse(true) |
| 425 | scrollThumbStyle lipgloss.Style |
| 426 | scrollTrackStyle lipgloss.Style |
| 427 | ) |
| 428 | |
| 429 | // renderTranscript draws the viewport's visible window with a scrollbar in the |
| 430 | // last column and the active selection reverse-highlighted. The content lines |
| 431 | // (m.wrappedLines) are already padded to cw by wrapTranscript, so this stays |
| 432 | // cheap per frame — important because a drag re-renders on every mouse move. |
| 433 | func (m chatTUI) renderTranscript() string { |
| 434 | h := m.viewport.Height() |
| 435 | if h <= 0 { |
| 436 | return "" |
| 437 | } |
| 438 | cw := m.viewport.Width() // content width; the scrollbar occupies one more column |
| 439 | lines := m.wrappedLines |
| 440 | total := len(lines) |
| 441 | yoff := m.viewport.YOffset() |
| 442 | start, end := m.sel.ordered() |
| 443 | thumbStart, thumbSize := scrollbarThumb(h, yoff, total) |
| 444 | blank := strings.Repeat(" ", cw) |
| 445 | |
| 446 | rows := make([]string, h) |
| 447 | bar := make([]string, h) |
| 448 | for r := range h { |
| 449 | idx := yoff + r |
| 450 | line := blank // off-content rows fill to width |
| 451 | if idx >= 0 && idx < total { |
| 452 | line = lines[idx] // already cw-wide from wrapTranscript |
| 453 | } |
| 454 | if m.sel.active && !m.sel.empty() { |
| 455 | if lo, hi, ok := selSpan(idx, start, end, cw); ok { |
| 456 | line = lipgloss.StyleRanges(line, lipgloss.NewRange(lo, hi, selStyle)) |
| 457 | } |
| 458 | } |
| 459 | rows[r] = line |
| 460 | bar[r] = scrollbarCell(r, total, h, thumbStart, thumbSize) |
| 461 | } |
| 462 | return lipgloss.JoinHorizontal(lipgloss.Top, strings.Join(rows, "\n"), strings.Join(bar, "\n")) |
| 463 | } |
| 464 | |
| 465 | // selSpan returns the [lo, hi) visual-column span of the selection on content |
| 466 | // line idx (false when the line is outside the selection). cw bounds the span |
| 467 | // so a multi-line selection highlights through the right edge. |
| 468 | func selSpan(idx int, start, end selPos, cw int) (lo, hi int, ok bool) { |
| 469 | if idx < start.line || idx > end.line { |
| 470 | return 0, 0, false |
| 471 | } |
| 472 | lo, hi = 0, cw |
| 473 | if idx == start.line { |
| 474 | lo = start.col |
| 475 | } |
| 476 | if idx == end.line { |
| 477 | hi = end.col |
| 478 | } |
| 479 | if hi > cw { |
| 480 | hi = cw |
| 481 | } |
| 482 | if lo >= hi { |
| 483 | return 0, 0, false |
| 484 | } |
| 485 | return lo, hi, true |
| 486 | } |
| 487 | |
| 488 | // scrollbarThumb returns the thumb's [start, start+size) row span for a viewport |
| 489 | // of `height` rows showing `total` content lines scrolled to `yoff`. |
| 490 | func scrollbarThumb(height, yoff, total int) (start, size int) { |
| 491 | if total <= height { |
| 492 | return 0, 0 // no overflow → no thumb |
| 493 | } |
| 494 | size = max(height*height/total, 1) |
| 495 | maxYoff := total - height |
| 496 | start = min(yoff*(height-size)/maxYoff, height-size) |
| 497 | return start, size |
| 498 | } |
| 499 | |
| 500 | func scrollbarYOffset(height, row, total, grabOffset int) int { |
| 501 | if total <= height { |
| 502 | return 0 |
| 503 | } |
| 504 | _, thumbSize := scrollbarThumb(height, 0, total) |
| 505 | maxTop := height - thumbSize |
| 506 | if maxTop <= 0 { |
| 507 | return 0 |
| 508 | } |
| 509 | top := min(max(row-grabOffset, 0), maxTop) |
| 510 | maxYoff := total - height |
| 511 | return (top*maxYoff + maxTop/2) / maxTop |
| 512 | } |
| 513 | |
| 514 | func scrollbarCell(row, total, height, thumbStart, thumbSize int) string { |
| 515 | if total <= height { |
| 516 | return " " |
| 517 | } |
| 518 | if row >= thumbStart && row < thumbStart+thumbSize { |
| 519 | return scrollThumbStyle.Render("█") |
| 520 | } |
| 521 | return scrollTrackStyle.Render("│") |
| 522 | } |
| 523 | |
| 524 | func (m chatTUI) inScrollbar(x, y int) bool { |
| 525 | if m.nativeScrollback { |
| 526 | return false |
| 527 | } |
| 528 | h := m.viewport.Height() |
| 529 | return h > 0 && y >= 0 && y < h && x == m.viewport.Width() && len(m.wrappedLines) > h |
| 530 | } |
| 531 | |
| 532 | func (m chatTUI) scrollbarGrabRowOffset(row int) int { |
| 533 | thumbStart, thumbSize := scrollbarThumb(m.viewport.Height(), m.viewport.YOffset(), len(m.wrappedLines)) |
| 534 | if row >= thumbStart && row < thumbStart+thumbSize { |
| 535 | return row - thumbStart |
| 536 | } |
| 537 | return thumbSize / 2 |
| 538 | } |
| 539 | |
| 540 | func (m *chatTUI) dragScrollbar(row int) { |
| 541 | m.viewport.SetYOffset(scrollbarYOffset(m.viewport.Height(), row, len(m.wrappedLines), m.scrollbarGrabOffset)) |
| 542 | // Sync immediately so a streaming event between drag motions cannot see a |
| 543 | // stale followTail and yank the reader back to the bottom (#6430/#6978). |
| 544 | m.syncScrollModeAfterGesture() |
| 545 | } |
| 546 | |
| 547 | // transcriptCaret maps a screen cell (x, y) in the transcript region to an |
| 548 | // absolute content position, clamping to the visible window. |
| 549 | func (m chatTUI) transcriptCaret(x, y int) selPos { |
| 550 | h := m.viewport.Height() |
| 551 | if y < 0 { |
| 552 | y = 0 |
| 553 | } |
| 554 | if y > h-1 { |
| 555 | y = h - 1 |
| 556 | } |
| 557 | if x < 0 { |
| 558 | x = 0 |
| 559 | } |
| 560 | if cw := m.viewport.Width(); x > cw { |
| 561 | x = cw |
| 562 | } |
| 563 | return selPos{line: m.viewport.YOffset() + y, col: x} |
| 564 | } |
| 565 |