返回 DeepSeek-Reasonix
transcript_copy.go
根目录 / internal / cli / transcript_copy.go
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
326 lines GO