// 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 }