| 1 | package cli |
| 2 | |
| 3 | import "strings" |
| 4 | |
| 5 | // wrapCache keeps the viewport's wrapped line list in sync with transcript |
| 6 | // blocks without re-wrapping the entire history on every streaming update. |
| 7 | // |
| 8 | // Contract: |
| 9 | // - wrapWidth is the content width used for the cached lines. |
| 10 | // - wrapBlockCount is how many leading transcript blocks are fully reflected |
| 11 | // in wrapBlockLines / the corresponding prefix of wrappedLines. |
| 12 | // - invalidateWrapFrom(i) drops the cache from block i onward so the next |
| 13 | // sync only re-wraps the suffix (streaming answer rewrites, tool tails). |
| 14 | // - Full rebuild is reserved for width change, history shrink, or an empty |
| 15 | // cache — never for a routine transcriptDirty flag alone. |
| 16 | func (m *chatTUI) clearWrapCache() { |
| 17 | m.wrappedLines = nil |
| 18 | m.wrapWidth = 0 |
| 19 | m.wrapBlockCount = 0 |
| 20 | m.wrapBlockLines = nil |
| 21 | } |
| 22 | |
| 23 | // syncWrappedLines ensures wrapBlockLines/wrappedLines match m.transcript at |
| 24 | // contentW. forceFull rebuilds every block; otherwise only blocks from |
| 25 | // wrapBlockCount onward are wrapped (suffix path after invalidateWrapFrom). |
| 26 | // Returns true when the flat line list changed and the viewport must be fed. |
| 27 | func (m *chatTUI) syncWrappedLines(contentW int, forceFull bool) bool { |
| 28 | if contentW <= 0 { |
| 29 | contentW = 1 |
| 30 | } |
| 31 | n := len(m.transcript) |
| 32 | if forceFull || contentW != m.wrapWidth || n < m.wrapBlockCount { |
| 33 | return m.rebuildWrappedLinesFull(contentW) |
| 34 | } |
| 35 | // Heal a desynced block slice (should not happen if invalidate is used). |
| 36 | if len(m.wrapBlockLines) != m.wrapBlockCount { |
| 37 | if m.wrapBlockCount > len(m.wrapBlockLines) { |
| 38 | m.wrapBlockCount = len(m.wrapBlockLines) |
| 39 | } else { |
| 40 | m.wrapBlockLines = m.wrapBlockLines[:m.wrapBlockCount] |
| 41 | } |
| 42 | m.wrappedLines = flattenBlockWraps(m.wrapBlockLines) |
| 43 | } |
| 44 | if n == m.wrapBlockCount { |
| 45 | return false |
| 46 | } |
| 47 | // Suffix-only: re-wrap mutated/new blocks from wrapBlockCount..n. |
| 48 | for i := m.wrapBlockCount; i < n; i++ { |
| 49 | blockLines := wrapBlockLines(m.transcript[i], contentW) |
| 50 | m.wrapBlockLines = append(m.wrapBlockLines, blockLines) |
| 51 | } |
| 52 | // Rebuild the flat list from the (prefix-stable + new suffix) block wraps |
| 53 | // into a fresh slice so the viewport never aliases a growing backing array. |
| 54 | m.wrappedLines = flattenBlockWraps(m.wrapBlockLines) |
| 55 | m.wrapBlockCount = n |
| 56 | m.wrapWidth = contentW |
| 57 | return true |
| 58 | } |
| 59 | |
| 60 | func (m *chatTUI) rebuildWrappedLinesFull(contentW int) bool { |
| 61 | if contentW <= 0 { |
| 62 | contentW = 1 |
| 63 | } |
| 64 | n := len(m.transcript) |
| 65 | m.wrapBlockLines = make([][]string, n) |
| 66 | for i := 0; i < n; i++ { |
| 67 | m.wrapBlockLines[i] = wrapBlockLines(m.transcript[i], contentW) |
| 68 | } |
| 69 | // Prefer per-block flatten over join-then-wrap so streaming suffix rebuilds |
| 70 | // stay consistent with append-only updates (both use wrapBlockLines). |
| 71 | m.wrappedLines = flattenBlockWraps(m.wrapBlockLines) |
| 72 | m.wrapBlockCount = n |
| 73 | m.wrapWidth = contentW |
| 74 | return true |
| 75 | } |
| 76 | |
| 77 | // feedViewportContent pushes the wrap cache into the bubbles viewport without |
| 78 | // re-joining the document to a single string. The line slice is cloned so later |
| 79 | // cache growth cannot mutate storage the viewport still holds. |
| 80 | func (m *chatTUI) feedViewportContent() { |
| 81 | if len(m.wrappedLines) == 0 { |
| 82 | m.viewport.SetContentLines(nil) |
| 83 | return |
| 84 | } |
| 85 | lines := make([]string, len(m.wrappedLines)) |
| 86 | copy(lines, m.wrappedLines) |
| 87 | m.viewport.SetContentLines(lines) |
| 88 | } |
| 89 | |
| 90 | // wrapBlockLines wraps one transcript block to width as a line slice. |
| 91 | func wrapBlockLines(block string, width int) []string { |
| 92 | wrapped := wrapTranscript(block, width) |
| 93 | if wrapped == "" { |
| 94 | return []string{""} |
| 95 | } |
| 96 | return strings.Split(wrapped, "\n") |
| 97 | } |
| 98 | |
| 99 | // flattenBlockWraps concatenates per-block wrapped line groups. Block boundaries |
| 100 | // in the transcript are already forced newlines (strings.Join(blocks, "\n")), so |
| 101 | // concatenating independent wraps matches the joined document for our content. |
| 102 | func flattenBlockWraps(blocks [][]string) []string { |
| 103 | if len(blocks) == 0 { |
| 104 | return nil |
| 105 | } |
| 106 | n := 0 |
| 107 | for _, b := range blocks { |
| 108 | n += len(b) |
| 109 | } |
| 110 | out := make([]string, 0, n) |
| 111 | for _, b := range blocks { |
| 112 | out = append(out, b...) |
| 113 | } |
| 114 | return out |
| 115 | } |
| 116 | |
| 117 | // wrappedContentString returns the viewport payload for tests/debug. |
| 118 | func (m chatTUI) wrappedContentString() string { |
| 119 | if len(m.wrappedLines) == 0 { |
| 120 | return "" |
| 121 | } |
| 122 | return strings.Join(m.wrappedLines, "\n") |
| 123 | } |
| 124 | |
| 125 | // invalidateWrapFrom drops the wrap cache from block index onward so the next |
| 126 | // syncWrappedLines only re-wraps the suffix. Used by setTranscriptBlock and any |
| 127 | // in-place rewrite of a live stream slot (answer, tool tail, …). |
| 128 | func (m *chatTUI) invalidateWrapFrom(index int) { |
| 129 | if index < 0 { |
| 130 | index = 0 |
| 131 | } |
| 132 | if index >= m.wrapBlockCount { |
| 133 | return |
| 134 | } |
| 135 | m.wrapBlockLines = m.wrapBlockLines[:index] |
| 136 | m.wrapBlockCount = index |
| 137 | m.wrappedLines = flattenBlockWraps(m.wrapBlockLines) |
| 138 | } |
| 139 | |
| 140 | // rewriteTranscriptBlock updates a block's rendered text and invalidates the |
| 141 | // wrap suffix from that index. Prefer this over bare transcript[i] = … so the |
| 142 | // streaming hot path never forces a full-history rebuild. |
| 143 | func (m *chatTUI) rewriteTranscriptBlock(index int, rendered string) { |
| 144 | if index < 0 || index >= len(m.transcript) { |
| 145 | return |
| 146 | } |
| 147 | m.transcript[index] = rendered |
| 148 | m.invalidateWrapFrom(index) |
| 149 | } |
| 150 |