| 📦 Turbo Core — files rewritten outside reload into their windows; the agent window wraps what you type and keeps a long paste aside as a token (#28); configrepo fetches a shared .turbo-<slug> from a forge URL (#25) fe23288 k33g 7h ago | 1 | // Pasting into the box: a long paste becomes a token, and the agent gets the |
| 2 | // text the token stands for. |
| 3 | |
| 4 | package acp |
| 5 | |
| 6 | import ( |
| 7 | "fmt" |
| 8 | "strings" |
| 9 | "unicode/utf8" |
| 10 | |
| 11 | "github.com/gdamore/tcell/v2" |
| 12 | ) |
| 13 | |
| 14 | // Paste is text that was pasted into the box and kept aside: the box shows |
| 15 | // Token where it went, and the agent receives Text in its place. |
| 16 | // |
| 17 | // acp.Paste{Token: "[Pasted #1 · 6 lines · 700 chars]", Text: trace} |
| 18 | type Paste struct { |
| 19 | Token string |
| 20 | Text string |
| 21 | } |
| 22 | |
| 23 | // pasteInlineLimit is how long a single-line paste may be and still go into |
| 24 | // the box as if typed. Longer, and it is kept aside like a multi-line one: a |
| 25 | // box three rows tall filled by one paste is as unreadable as forty lines. |
| 26 | const pasteInlineLimit = 200 |
| 27 | |
| 28 | // Paste puts pasted text into the box at the cursor, whichever pane had the |
| 29 | // focus — a paste is something to send, and the box is where that happens. |
| 30 | // |
| 31 | // A short single line goes in as if typed: a path, a command, an identifier |
| 32 | // is what you want to edit around. Anything with a line break in it, or |
| 33 | // longer than pasteInlineLimit, is **kept aside and stands in the box as a |
| 34 | // token** — `[Pasted #1 · 6 lines · 700 chars]` — because a box a few rows |
| 35 | // tall cannot show forty lines of log, and what was typed around them would be |
| 36 | // pushed off the screen. Send gives the agent the text, byte for byte, where |
| 37 | // the token was; the conversation keeps the token, so the exchange stays |
| 38 | // readable afterwards too. Deleting the paste instead is no answer: the text |
| 39 | // is what the model needs. |
| 40 | // |
| 41 | // A trailing newline is dropped: copying a line usually brings one along, and |
| 42 | // it would otherwise make a one-line paste a two-line token. |
| 43 | func (v *View) Paste(text string) { |
| 44 | text = strings.ReplaceAll(text, "\r\n", "\n") |
| 45 | text = strings.ReplaceAll(text, "\r", "\n") |
| 46 | text = strings.TrimSuffix(text, "\n") |
| 47 | if text == "" { |
| 48 | return |
| 49 | } |
| 50 | v.onInput = true |
| 51 | |
| 52 | if !strings.Contains(text, "\n") && utf8.RuneCountInString(text) <= pasteInlineLimit { |
| 53 | v.insertText(text) |
| 54 | return |
| 55 | } |
| 56 | |
| 57 | v.pasteCount++ |
| 58 | paste := Paste{Token: pasteToken(v.pasteCount, text), Text: text} |
| 59 | v.pastes = append(v.pastes, paste) |
| 60 | v.insertText(paste.Token) |
| 61 | } |
| 62 | |
| 63 | // pasteToken is what stands in the box for a paste: its number, so two are |
| 64 | // told apart, and its size, so you know what you are sending. |
| 65 | func pasteToken(number int, text string) string { |
| 66 | lines := strings.Count(text, "\n") + 1 |
| 67 | return fmt.Sprintf("[Pasted #%d · %s · %s]", number, counted(lines, "line"), counted(utf8.RuneCountInString(text), "char")) |
| 68 | } |
| 69 | |
| 70 | // counted renders a count with its noun. |
| 71 | func counted(count int, noun string) string { |
| 72 | if count == 1 { |
| 73 | return "1 " + noun |
| 74 | } |
| 75 | return fmt.Sprintf("%d %ss", count, noun) |
| 76 | } |
| 77 | |
| 78 | // insertText types a run of characters — no line breaks — at the cursor. |
| 79 | func (v *View) insertText(text string) { |
| 80 | for _, r := range text { |
| 81 | v.insert(r) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // pasteClipboard pastes what the editor's clipboard holds, when the editor |
| 86 | // has said how to read it. |
| 87 | func (v *View) pasteClipboard() bool { |
| 88 | if v.OnPaste == nil { |
| 89 | return false |
| 90 | } |
| 91 | v.Paste(v.OnPaste()) |
| 92 | return true |
| 93 | } |
| 94 | |
| 95 | // isPasteKey reports whether a key is the editor's own Paste — Shift-Ins, as |
| 96 | // the Edit menu says. Ctrl-V is accepted beside it in handleWindowKey, as |
| 97 | // Ctrl-C is for Copy. |
| 98 | func isPasteKey(ev *tcell.EventKey) bool { |
| 99 | return ev.Key() == tcell.KeyInsert && ev.Modifiers()&tcell.ModShift != 0 |
| 100 | } |
| 101 | |
| 102 | // pastesIn returns the pastes whose token is still in a text — the ones Send |
| 103 | // has to expand. A token that was deleted takes its text with it. |
| 104 | func (v *View) pastesIn(text string) []Paste { |
| 105 | var out []Paste |
| 106 | for _, paste := range v.pastes { |
| 107 | if strings.Contains(text, paste.Token) { |
| 108 | out = append(out, paste) |
| 109 | } |
| 110 | } |
| 111 | return out |
| 112 | } |
| 113 | |
| 114 | // tokenSpan is where a paste token sits in a line, in runes. |
| 115 | type tokenSpan struct{ start, end int } |
| 116 | |
| 117 | // tokensIn returns every paste token in a line, in order. |
| 118 | // |
| 119 | // Tokens are found by their text, not remembered by position: the line is |
| 120 | // edited by a dozen functions, and a position kept alongside would be wrong |
| 121 | // the first time one of them forgot to move it. |
| 122 | func (v *View) tokensIn(line string) []tokenSpan { |
| 123 | var spans []tokenSpan |
| 124 | for _, paste := range v.pastes { |
| 125 | rest, offset := line, 0 |
| 126 | for { |
| 127 | at := strings.Index(rest, paste.Token) |
| 128 | if at < 0 { |
| 129 | break |
| 130 | } |
| 131 | start := offset + utf8.RuneCountInString(rest[:at]) |
| 132 | spans = append(spans, tokenSpan{start: start, end: start + utf8.RuneCountInString(paste.Token)}) |
| 133 | skip := at + len(paste.Token) |
| 134 | rest, offset = rest[skip:], start+utf8.RuneCountInString(paste.Token) |
| 135 | } |
| 136 | } |
| 137 | return spans |
| 138 | } |
| 139 | |
| 140 | // tokenEndingAt returns the token whose last rune is just before a column. |
| 141 | func (v *View) tokenEndingAt(column int) (tokenSpan, bool) { |
| 142 | for _, span := range v.tokensIn(v.input[v.cursor]) { |
| 143 | if span.end == column { |
| 144 | return span, true |
| 145 | } |
| 146 | } |
| 147 | return tokenSpan{}, false |
| 148 | } |
| 149 | |
| 150 | // tokenStartingAt returns the token whose first rune is at a column. |
| 151 | func (v *View) tokenStartingAt(column int) (tokenSpan, bool) { |
| 152 | for _, span := range v.tokensIn(v.input[v.cursor]) { |
| 153 | if span.start == column { |
| 154 | return span, true |
| 155 | } |
| 156 | } |
| 157 | return tokenSpan{}, false |
| 158 | } |
| 159 | |
| 160 | // leaveToken moves a cursor that has landed inside a token to its nearer |
| 161 | // edge. Arrow keys step over tokens, so only a move by row can land inside |
| 162 | // one; a token is one thing, and typing into the middle of it would break it |
| 163 | // into text that stands for nothing. |
| 164 | func (v *View) leaveToken() { |
| 165 | for _, span := range v.tokensIn(v.input[v.cursor]) { |
| 166 | if v.column > span.start && v.column < span.end { |
| 167 | if v.column-span.start < span.end-v.column { |
| 168 | v.column = span.start |
| 169 | } else { |
| 170 | v.column = span.end |
| 171 | } |
| 172 | return |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // removeSpan takes a run of runes out of the cursor's line and leaves the |
| 178 | // cursor where the run began. |
| 179 | func (v *View) removeSpan(span tokenSpan) { |
| 180 | runes := v.runes() |
| 181 | v.input[v.cursor] = string(append(append([]rune{}, runes[:span.start]...), runes[span.end:]...)) |
| 182 | v.column = span.start |
| 183 | } |
| 184 | |
| 185 | // inToken reports whether a column of a line is inside one of its tokens. |
| 186 | func inToken(spans []tokenSpan, at int) bool { |
| 187 | for _, span := range spans { |
| 188 | if at >= span.start && at < span.end { |
| 189 | return true |
| 190 | } |
| 191 | } |
| 192 | return false |
| 193 | } |
| 194 | |
| 195 | // expandPastes puts each paste's text where its token stands, in the text |
| 196 | // blocks of a prompt and nowhere else. |
| 197 | // |
| 198 | // After the mentions have been cut out, not before: a paste is sent byte for |
| 199 | // byte, so an "@name" inside a pasted log must stay the characters it is and |
| 200 | // not become a file. |
| 201 | func expandPastes(blocks []ContentBlock, pastes []Paste) []ContentBlock { |
| 202 | if len(pastes) == 0 { |
| 203 | return blocks |
| 204 | } |
| 205 | for i := range blocks { |
| 206 | if blocks[i].Type != ContentText { |
| 207 | continue |
| 208 | } |
| 209 | for _, paste := range pastes { |
| 210 | blocks[i].Text = strings.ReplaceAll(blocks[i].Text, paste.Token, paste.Text) |
| 211 | } |
| 212 | } |
| 213 | return blocks |
| 214 | } |