| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "encoding/base64" |
| 5 | "strconv" |
| 6 | "strings" |
| 7 | |
| 8 | "github.com/charmbracelet/x/ansi" |
| 9 | ) |
| 10 | |
| 11 | const ( |
| 12 | copySpanStartPrefix = "\x1b]1337;reasonix-copy-span=" |
| 13 | copySpanEndPrefix = "\x1b]1337;reasonix-copy-span-end=" |
| 14 | copySpanTerminator = "\x07" |
| 15 | copyOmitSpanID = "gutter" |
| 16 | ) |
| 17 | |
| 18 | var copyOmitSpanStart = copySpanStartMarker(copyOmitSpanID, "") |
| 19 | |
| 20 | func copySpanStartMarker(id, source string) string { |
| 21 | encoded := base64.RawURLEncoding.EncodeToString([]byte(source)) |
| 22 | return copySpanStartPrefix + id + ";" + encoded + copySpanTerminator |
| 23 | } |
| 24 | |
| 25 | func copySpanEndMarker(id string) string { |
| 26 | return copySpanEndPrefix + id + copySpanTerminator |
| 27 | } |
| 28 | |
| 29 | func copyOmitSpan(rendered string) string { |
| 30 | return copyOmitSpanStart + rendered + copySpanEndMarker(copyOmitSpanID) |
| 31 | } |
| 32 | |
| 33 | // buildCopyTranscript renders semantic Markdown only when a copy is requested. |
| 34 | // The visible text stays byte-for-byte equivalent after ANSI stripping, while |
| 35 | // copy spans retain math source and identify render-only decorations. |
| 36 | func (m chatTUI) buildCopyTranscript(contentWidth int) (string, int, bool) { |
| 37 | if len(m.transcriptSources) != len(m.transcript) { |
| 38 | return "", 0, false |
| 39 | } |
| 40 | var b strings.Builder |
| 41 | markers := 0 |
| 42 | for i, source := range m.transcriptSources { |
| 43 | if i > 0 { |
| 44 | b.WriteByte('\n') |
| 45 | } |
| 46 | var rendered string |
| 47 | switch source.kind { |
| 48 | case transcriptSourceMarkdown: |
| 49 | rendered = renderAssistantMarkdownCopy(source.raw, contentWidth, strconv.Itoa(i)) |
| 50 | case transcriptSourceReplayBundle: |
| 51 | rendered = m.renderReplayBundleCopy(source, contentWidth, strconv.Itoa(i)) |
| 52 | case transcriptSourceReasoning: |
| 53 | rendered = reasoningBlockCopy(source.raw, m.width, source.maxLines) |
| 54 | default: |
| 55 | rendered = m.transcript[i] |
| 56 | if source.copyRendered != "" { |
| 57 | rendered = source.copyRendered |
| 58 | } |
| 59 | } |
| 60 | markers += strings.Count(rendered, copySpanStartPrefix) |
| 61 | b.WriteString(rendered) |
| 62 | } |
| 63 | return b.String(), markers, true |
| 64 | } |
| 65 | |
| 66 | type copyMathSpan struct { |
| 67 | start int |
| 68 | end int |
| 69 | id string |
| 70 | source string |
| 71 | } |
| 72 | |
| 73 | type copyOmittedRange struct { |
| 74 | start int |
| 75 | end int |
| 76 | } |
| 77 | |
| 78 | type copyTranscriptLine struct { |
| 79 | text string |
| 80 | math []copyMathSpan |
| 81 | omit []copyOmittedRange |
| 82 | } |
| 83 | |
| 84 | type activeCopySpan struct { |
| 85 | id string |
| 86 | source string |
| 87 | start int |
| 88 | } |
| 89 | |
| 90 | func parseCopyTranscript(wrapped string) ([]copyTranscriptLine, int, bool) { |
| 91 | rawLines := strings.Split(wrapped, "\n") |
| 92 | lines := make([]copyTranscriptLine, 0, len(rawLines)) |
| 93 | var active *activeCopySpan |
| 94 | parsedMarkers := 0 |
| 95 | |
| 96 | for _, raw := range rawLines { |
| 97 | var clean strings.Builder |
| 98 | var math []copyMathSpan |
| 99 | var omit []copyOmittedRange |
| 100 | column := 0 |
| 101 | position := 0 |
| 102 | |
| 103 | for position < len(raw) { |
| 104 | startAt := strings.Index(raw[position:], copySpanStartPrefix) |
| 105 | endAt := strings.Index(raw[position:], copySpanEndPrefix) |
| 106 | if startAt >= 0 { |
| 107 | startAt += position |
| 108 | } |
| 109 | if endAt >= 0 { |
| 110 | endAt += position |
| 111 | } |
| 112 | |
| 113 | markerAt := -1 |
| 114 | isStart := false |
| 115 | switch { |
| 116 | case startAt >= 0 && (endAt < 0 || startAt < endAt): |
| 117 | markerAt, isStart = startAt, true |
| 118 | case endAt >= 0: |
| 119 | markerAt = endAt |
| 120 | } |
| 121 | if markerAt < 0 { |
| 122 | chunk := raw[position:] |
| 123 | clean.WriteString(chunk) |
| 124 | column += ansi.StringWidth(chunk) |
| 125 | break |
| 126 | } |
| 127 | |
| 128 | chunk := raw[position:markerAt] |
| 129 | clean.WriteString(chunk) |
| 130 | column += ansi.StringWidth(chunk) |
| 131 | |
| 132 | prefix := copySpanEndPrefix |
| 133 | if isStart { |
| 134 | prefix = copySpanStartPrefix |
| 135 | } |
| 136 | payloadStart := markerAt + len(prefix) |
| 137 | terminatorAt := strings.Index(raw[payloadStart:], copySpanTerminator) |
| 138 | if terminatorAt < 0 { |
| 139 | return nil, 0, false |
| 140 | } |
| 141 | terminatorAt += payloadStart |
| 142 | payload := raw[payloadStart:terminatorAt] |
| 143 | position = terminatorAt + len(copySpanTerminator) |
| 144 | |
| 145 | if isStart { |
| 146 | parts := strings.SplitN(payload, ";", 2) |
| 147 | if len(parts) != 2 || active != nil { |
| 148 | return nil, 0, false |
| 149 | } |
| 150 | decoded, err := base64.RawURLEncoding.DecodeString(parts[1]) |
| 151 | if err != nil { |
| 152 | return nil, 0, false |
| 153 | } |
| 154 | active = &activeCopySpan{id: parts[0], source: string(decoded), start: column} |
| 155 | parsedMarkers++ |
| 156 | continue |
| 157 | } |
| 158 | |
| 159 | if active == nil || active.id != payload { |
| 160 | return nil, 0, false |
| 161 | } |
| 162 | if active.source == "" { |
| 163 | omit = append(omit, copyOmittedRange{start: active.start, end: column}) |
| 164 | } else { |
| 165 | math = append(math, copyMathSpan{ |
| 166 | start: active.start, end: column, id: active.id, source: active.source, |
| 167 | }) |
| 168 | } |
| 169 | active = nil |
| 170 | } |
| 171 | |
| 172 | if active != nil { |
| 173 | if active.source == "" { |
| 174 | omit = append(omit, copyOmittedRange{start: active.start, end: column}) |
| 175 | } else { |
| 176 | math = append(math, copyMathSpan{ |
| 177 | start: active.start, end: column, id: active.id, source: active.source, |
| 178 | }) |
| 179 | } |
| 180 | active.start = 0 |
| 181 | } |
| 182 | lines = append(lines, copyTranscriptLine{text: clean.String(), math: math, omit: omit}) |
| 183 | } |
| 184 | if active != nil { |
| 185 | return nil, 0, false |
| 186 | } |
| 187 | return lines, parsedMarkers, true |
| 188 | } |
| 189 | |
| 190 | func (m chatTUI) copyTranscriptLines() ([]copyTranscriptLine, bool) { |
| 191 | contentWidth := m.viewport.Width() |
| 192 | marked, expectedMarkers, ok := m.buildCopyTranscript(contentWidth) |
| 193 | if !ok { |
| 194 | return nil, false |
| 195 | } |
| 196 | lines, parsedMarkers, ok := parseCopyTranscript(wrapTranscript(marked, contentWidth)) |
| 197 | if !ok || parsedMarkers != expectedMarkers || len(lines) != len(m.wrappedLines) { |
| 198 | return nil, false |
| 199 | } |
| 200 | for i := range lines { |
| 201 | if ansi.Strip(lines[i].text) != ansi.Strip(m.wrappedLines[i]) { |
| 202 | return nil, false |
| 203 | } |
| 204 | } |
| 205 | return lines, true |
| 206 | } |
| 207 | |
| 208 | func selectedDisplayText(lines []string, start, end selPos) string { |
| 209 | var out []string |
| 210 | for idx := start.line; idx <= end.line && idx < len(lines); idx++ { |
| 211 | lo, hi := 0, ansi.StringWidth(lines[idx]) |
| 212 | if idx == start.line { |
| 213 | lo = start.col |
| 214 | } |
| 215 | if idx == end.line { |
| 216 | hi = end.col |
| 217 | } |
| 218 | out = append(out, strings.TrimRight(ansi.Strip(ansi.Cut(lines[idx], lo, hi)), " ")) |
| 219 | } |
| 220 | return strings.Join(out, "\n") |
| 221 | } |
| 222 | |
| 223 | func selectedCopyText(lines []copyTranscriptLine, start, end selPos) string { |
| 224 | seen := make(map[string]bool) |
| 225 | var out []string |
| 226 | for idx := start.line; idx <= end.line && idx < len(lines); idx++ { |
| 227 | line := lines[idx] |
| 228 | lo, hi := 0, ansi.StringWidth(line.text) |
| 229 | if idx == start.line { |
| 230 | lo = start.col |
| 231 | } |
| 232 | if idx == end.line { |
| 233 | hi = end.col |
| 234 | } |
| 235 | for _, span := range line.omit { |
| 236 | if span.end <= lo { |
| 237 | continue |
| 238 | } |
| 239 | if span.start > lo { |
| 240 | break |
| 241 | } |
| 242 | lo = min(span.end, hi) |
| 243 | } |
| 244 | |
| 245 | var selected strings.Builder |
| 246 | cursor := lo |
| 247 | touchedMath := false |
| 248 | for _, span := range line.math { |
| 249 | if span.end <= lo || span.start >= hi { |
| 250 | continue |
| 251 | } |
| 252 | touchedMath = true |
| 253 | if span.start > cursor { |
| 254 | selected.WriteString(ansi.Strip(ansi.Cut(line.text, cursor, min(span.start, hi)))) |
| 255 | } |
| 256 | if !seen[span.id] { |
| 257 | selected.WriteString(span.source) |
| 258 | seen[span.id] = true |
| 259 | } |
| 260 | cursor = max(cursor, min(span.end, hi)) |
| 261 | } |
| 262 | if cursor < hi { |
| 263 | selected.WriteString(ansi.Strip(ansi.Cut(line.text, cursor, hi))) |
| 264 | } |
| 265 | if selected.Len() == 0 && touchedMath { |
| 266 | continue |
| 267 | } |
| 268 | out = append(out, strings.TrimRight(selected.String(), " ")) |
| 269 | } |
| 270 | return strings.Join(out, "\n") |
| 271 | } |
| 272 | |
| 273 | // selectedText is the plain text of the active display-cell selection. Math is |
| 274 | // reconstructed on demand from semantic transcript sources; if the marked copy |
| 275 | // rendition ever diverges from the visible transcript, the safe fallback keeps |
| 276 | // the exact displayed text rather than applying mismatched coordinates. |
| 277 | func (m chatTUI) selectedText() string { |
| 278 | if !m.sel.active || m.sel.empty() { |
| 279 | return "" |
| 280 | } |
| 281 | start, end := m.sel.ordered() |
| 282 | if lines, ok := m.copyTranscriptLines(); ok { |
| 283 | return selectedCopyText(lines, start, end) |
| 284 | } |
| 285 | return selectedDisplayText(m.wrappedLines, start, end) |
| 286 | } |
| 287 | |
| 288 | // commitConnectorBlock stores a copy-only rendition beside the visible fixed |
| 289 | // block so selection copy omits generated cells without guessing from text. |
| 290 | func (m *chatTUI) commitConnectorBlock(lines []string) { |
| 291 | rendered := connectorBlock(lines) |
| 292 | *m.pendingCommit = append(*m.pendingCommit, rendered) |
| 293 | m.appendTranscriptBlock(rendered, transcriptSource{ |
| 294 | kind: transcriptSourceFixed, |
| 295 | copyRendered: connectorBlockCopy(lines), |
| 296 | }) |
| 297 | } |
| 298 | |
| 299 | func (m *chatTUI) rewriteConnectorBlock(index int, lines []string) { |
| 300 | if index < 0 || index >= len(m.transcript) { |
| 301 | return |
| 302 | } |
| 303 | m.ensureTranscriptSources() |
| 304 | source := m.transcriptSources[index] |
| 305 | source.copyRendered = connectorBlockCopy(lines) |
| 306 | m.setTranscriptBlock(index, connectorBlock(lines), source) |
| 307 | } |
| 308 | |
| 309 | func reasoningBlockLines(raw string, width, maxLines int) []string { |
| 310 | w := max(width-len([]rune(connector)), 8) |
| 311 | var lines []string |
| 312 | for ln := range strings.SplitSeq(strings.TrimRight(raw, "\n"), "\n") { |
| 313 | for wl := range strings.SplitSeq(ansi.Wrap(expandTabs(ln), w, ""), "\n") { |
| 314 | lines = append(lines, dim(wl)) |
| 315 | } |
| 316 | } |
| 317 | if maxLines > 0 && len(lines) > maxLines { |
| 318 | lines = lines[len(lines)-maxLines:] |
| 319 | } |
| 320 | return lines |
| 321 | } |
| 322 | |
| 323 | func reasoningBlockCopy(raw string, width, maxLines int) string { |
| 324 | return connectorBlockCopy(reasoningBlockLines(raw, width, maxLines)) |
| 325 | } |
| 326 |