turbo-editors/turbo-corepublic Fork 0
fe2328870c726beecc5bc34fd3d05acba9fec94f
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

paste.go · 214 lines · 6.7 KBGo Blame HistoryRaw
📦 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 10h ago1// Pasting into the box: a long paste becomes a token, and the agent gets the
2// text the token stands for.
3
4package acp
5
6import (
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}
18type 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.
26const 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.
43func (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.
65func 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.
71func 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.
79func (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.
87func (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.
98func 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.
104func (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.
115type 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.
122func (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.
141func (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.
151func (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.
164func (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.
179func (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.
186func 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.
201func 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}