turbo-editors/turbo-corepublic Fork 0
b1c5e36e1d1a12e805a39c391e69028b649e6a87
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
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
// Pasting into the box: a long paste becomes a token, and the agent gets the
// text the token stands for.

package acp

import (
	"fmt"
	"strings"
	"unicode/utf8"

	"github.com/gdamore/tcell/v2"
)

// Paste is text that was pasted into the box and kept aside: the box shows
// Token where it went, and the agent receives Text in its place.
//
//	acp.Paste{Token: "[Pasted #1 · 6 lines · 700 chars]", Text: trace}
type Paste struct {
	Token string
	Text  string
}

// pasteInlineLimit is how long a single-line paste may be and still go into
// the box as if typed. Longer, and it is kept aside like a multi-line one: a
// box three rows tall filled by one paste is as unreadable as forty lines.
const pasteInlineLimit = 200

// Paste puts pasted text into the box at the cursor, whichever pane had the
// focus — a paste is something to send, and the box is where that happens.
//
// A short single line goes in as if typed: a path, a command, an identifier
// is what you want to edit around. Anything with a line break in it, or
// longer than pasteInlineLimit, is **kept aside and stands in the box as a
// token** — `[Pasted #1 · 6 lines · 700 chars]` — because a box a few rows
// tall cannot show forty lines of log, and what was typed around them would be
// pushed off the screen. Send gives the agent the text, byte for byte, where
// the token was; the conversation keeps the token, so the exchange stays
// readable afterwards too. Deleting the paste instead is no answer: the text
// is what the model needs.
//
// A trailing newline is dropped: copying a line usually brings one along, and
// it would otherwise make a one-line paste a two-line token.
func (v *View) Paste(text string) {
	text = strings.ReplaceAll(text, "\r\n", "\n")
	text = strings.ReplaceAll(text, "\r", "\n")
	text = strings.TrimSuffix(text, "\n")
	if text == "" {
		return
	}
	v.onInput = true

	if !strings.Contains(text, "\n") && utf8.RuneCountInString(text) <= pasteInlineLimit {
		v.insertText(text)
		return
	}

	v.pasteCount++
	paste := Paste{Token: pasteToken(v.pasteCount, text), Text: text}
	v.pastes = append(v.pastes, paste)
	v.insertText(paste.Token)
}

// pasteToken is what stands in the box for a paste: its number, so two are
// told apart, and its size, so you know what you are sending.
func pasteToken(number int, text string) string {
	lines := strings.Count(text, "\n") + 1
	return fmt.Sprintf("[Pasted #%d · %s · %s]", number, counted(lines, "line"), counted(utf8.RuneCountInString(text), "char"))
}

// counted renders a count with its noun.
func counted(count int, noun string) string {
	if count == 1 {
		return "1 " + noun
	}
	return fmt.Sprintf("%d %ss", count, noun)
}

// insertText types a run of characters — no line breaks — at the cursor.
func (v *View) insertText(text string) {
	for _, r := range text {
		v.insert(r)
	}
}

// pasteClipboard pastes what the editor's clipboard holds, when the editor
// has said how to read it.
func (v *View) pasteClipboard() bool {
	if v.OnPaste == nil {
		return false
	}
	v.Paste(v.OnPaste())
	return true
}

// isPasteKey reports whether a key is the editor's own Paste — Shift-Ins, as
// the Edit menu says. Ctrl-V is accepted beside it in handleWindowKey, as
// Ctrl-C is for Copy.
func isPasteKey(ev *tcell.EventKey) bool {
	return ev.Key() == tcell.KeyInsert && ev.Modifiers()&tcell.ModShift != 0
}

// pastesIn returns the pastes whose token is still in a text — the ones Send
// has to expand. A token that was deleted takes its text with it.
func (v *View) pastesIn(text string) []Paste {
	var out []Paste
	for _, paste := range v.pastes {
		if strings.Contains(text, paste.Token) {
			out = append(out, paste)
		}
	}
	return out
}

// tokenSpan is where a paste token sits in a line, in runes.
type tokenSpan struct{ start, end int }

// tokensIn returns every paste token in a line, in order.
//
// Tokens are found by their text, not remembered by position: the line is
// edited by a dozen functions, and a position kept alongside would be wrong
// the first time one of them forgot to move it.
func (v *View) tokensIn(line string) []tokenSpan {
	var spans []tokenSpan
	for _, paste := range v.pastes {
		rest, offset := line, 0
		for {
			at := strings.Index(rest, paste.Token)
			if at < 0 {
				break
			}
			start := offset + utf8.RuneCountInString(rest[:at])
			spans = append(spans, tokenSpan{start: start, end: start + utf8.RuneCountInString(paste.Token)})
			skip := at + len(paste.Token)
			rest, offset = rest[skip:], start+utf8.RuneCountInString(paste.Token)
		}
	}
	return spans
}

// tokenEndingAt returns the token whose last rune is just before a column.
func (v *View) tokenEndingAt(column int) (tokenSpan, bool) {
	for _, span := range v.tokensIn(v.input[v.cursor]) {
		if span.end == column {
			return span, true
		}
	}
	return tokenSpan{}, false
}

// tokenStartingAt returns the token whose first rune is at a column.
func (v *View) tokenStartingAt(column int) (tokenSpan, bool) {
	for _, span := range v.tokensIn(v.input[v.cursor]) {
		if span.start == column {
			return span, true
		}
	}
	return tokenSpan{}, false
}

// leaveToken moves a cursor that has landed inside a token to its nearer
// edge. Arrow keys step over tokens, so only a move by row can land inside
// one; a token is one thing, and typing into the middle of it would break it
// into text that stands for nothing.
func (v *View) leaveToken() {
	for _, span := range v.tokensIn(v.input[v.cursor]) {
		if v.column > span.start && v.column < span.end {
			if v.column-span.start < span.end-v.column {
				v.column = span.start
			} else {
				v.column = span.end
			}
			return
		}
	}
}

// removeSpan takes a run of runes out of the cursor's line and leaves the
// cursor where the run began.
func (v *View) removeSpan(span tokenSpan) {
	runes := v.runes()
	v.input[v.cursor] = string(append(append([]rune{}, runes[:span.start]...), runes[span.end:]...))
	v.column = span.start
}

// inToken reports whether a column of a line is inside one of its tokens.
func inToken(spans []tokenSpan, at int) bool {
	for _, span := range spans {
		if at >= span.start && at < span.end {
			return true
		}
	}
	return false
}

// expandPastes puts each paste's text where its token stands, in the text
// blocks of a prompt and nowhere else.
//
// After the mentions have been cut out, not before: a paste is sent byte for
// byte, so an "@name" inside a pasted log must stay the characters it is and
// not become a file.
func expandPastes(blocks []ContentBlock, pastes []Paste) []ContentBlock {
	if len(pastes) == 0 {
		return blocks
	}
	for i := range blocks {
		if blocks[i].Type != ContentText {
			continue
		}
		for _, paste := range pastes {
			blocks[i].Text = strings.ReplaceAll(blocks[i].Text, paste.Token, paste.Text)
		}
	}
	return blocks
}