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