turbo-editors/turbo-corepublic Fork 0
main
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_test.go · 273 lines · 8.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 11h ago1package acp_test
2
3import (
4 "strings"
5 "testing"
6
7 "github.com/gdamore/tcell/v2"
8
9 "rickub.com/turbo-editors/turbo-core/acp"
10 "rickub.com/turbo-editors/turbo-core/theme"
11 "rickub.com/turbo-editors/turbo-core/ui"
12)
13
14// trace is six lines of the kind of thing that gets pasted into an agent's
15// box: a log, longer than the box is tall.
16const trace = "panic: runtime error: index out of range [3] with length 3\n" +
17 "goroutine 1 [running]:\n" +
18 "main.pick(...)\n" +
19 "\t/src/p/main.go:12\n" +
20 "main.main()\n" +
21 "\t/src/p/main.go:7 +0x1d"
22
23func TestAMultiLinePasteStandsInTheBoxAsAToken(t *testing.T) {
24 // The ticket's own example: a paste a few rows tall would make the box
25 // unreadable and push what was typed around it off the screen.
26 view, _ := newView(t, 60, 12)
27 typeInto(view, "here is the trace ")
28 view.Paste(trace)
29 typeInto(view, " what is failing?")
30
31 want := "here is the trace [Pasted #1 · 6 lines · 151 chars] what is failing?"
32 if got := view.Input(); got != want {
33 t.Errorf("Input() = %q\nwant %q", got, want)
34 }
35}
36
37func TestAShortSingleLinePasteGoesInAsIfTyped(t *testing.T) {
38 // A path, a command, an identifier: what you paste to edit around. The
39 // newline copying a line brings along is dropped, not made a second line.
40 view, _ := newView(t, 60, 12)
41 view.Paste("go build ./...\n")
42
43 if got := view.Input(); got != "go build ./..." {
44 t.Errorf("Input() = %q, want the line itself", got)
45 }
46}
47
48func TestALongSingleLinePasteIsKeptAsideToo(t *testing.T) {
49 view, _ := newView(t, 60, 12)
50 view.Paste(strings.Repeat("x", 300))
51
52 if got := view.Input(); got != "[Pasted #1 · 1 line · 300 chars]" {
53 t.Errorf("Input() = %q, want a token for one long line", got)
54 }
55}
56
57func TestWindowsLineEndingsCountAsLines(t *testing.T) {
58 view, _ := newView(t, 60, 12)
59 view.Paste("one\r\ntwo\r\nthree\r\n")
60
61 if got := view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" {
62 t.Errorf("Input() = %q", got)
63 }
64}
65
66func TestTheAgentReceivesThePastedTextWhereTheTokenStood(t *testing.T) {
67 // This is the point: the token is for the box; the model gets the bytes.
68 view, agent := mentioningView(t, nil)
69 agent.handshake()
70 waitFor(t, "ready", view.Session().Ready)
71
72 typeInto(view, "here is the trace ")
73 view.Paste(trace)
74 typeInto(view, " what is failing?")
75 view.Send()
76
77 blocks := promptBlocks(t, agent.read())
78 if len(blocks) != 1 || blocks[0]["type"] != "text" {
79 t.Fatalf("the prompt is %v, want one text block", blocks)
80 }
81 if got := blocks[0]["text"]; got != "here is the trace "+trace+" what is failing?" {
82 t.Errorf("the agent received %q", got)
83 }
84
85 // The conversation keeps the token, so the exchange stays readable.
86 if got := textOf(view.Session().Entries(), acp.EntryUser); !strings.Contains(got, "[Pasted #1 · 6 lines") || strings.Contains(got, "goroutine") {
87 t.Errorf("the conversation says %q, want the token and not the trace", got)
88 }
89}
90
91func TestAFileNamedInsideAPasteIsNotTakenForAMention(t *testing.T) {
92 // A paste is sent byte for byte. An "@name" in a pasted log is characters,
93 // not a request to attach the file.
94 view, agent := mentioningView(t, func(string) (string, error) { return "package internal\n", nil })
95 agent.handshake()
96 waitFor(t, "ready", view.Session().Ready)
97
98 pasted := "see @internal/scanner.go for details\nand the next line"
99 view.Paste(pasted)
100 view.Send()
101
102 blocks := promptBlocks(t, agent.read())
103 if len(blocks) != 1 || blocks[0]["type"] != "text" || blocks[0]["text"] != pasted {
104 t.Errorf("the prompt is %v, want the pasted text alone, verbatim", blocks)
105 }
106}
107
108func TestAMentionTypedBesideATokenStillWorks(t *testing.T) {
109 view, agent := mentioningView(t, func(string) (string, error) { return "package internal\n", nil })
110 agent.handshake()
111 waitFor(t, "ready", view.Session().Ready)
112
113 typeInto(view, "compare @internal/scanner.go with ")
114 view.Paste(trace)
115 view.Send()
116
117 blocks := promptBlocks(t, agent.read())
118 if len(blocks) != 3 || blocks[1]["type"] != "resource" {
119 t.Fatalf("the prompt has %d blocks, want text, resource, text: %v", len(blocks), blocks)
120 }
121 if blocks[2]["text"] != " with "+trace {
122 t.Errorf("block 2 = %q, want the words and the trace", blocks[2]["text"])
123 }
124}
125
126func TestBackspaceRemovesAWholeToken(t *testing.T) {
127 // A token missing its last bracket stands for nothing, so the token is
128 // one thing to the editing keys.
129 view, agent := mentioningView(t, nil)
130 agent.handshake()
131 waitFor(t, "ready", view.Session().Ready)
132
133 typeInto(view, "a ")
134 view.Paste(trace)
135 view.HandleKey(tcell.NewEventKey(tcell.KeyBackspace2, 0, tcell.ModNone))
136
137 if got := view.Input(); got != "a " {
138 t.Errorf("Input() = %q after Backspace, want the token gone whole", got)
139 }
140
141 // And its text goes with it: nothing of the trace reaches the agent.
142 typeInto(view, "b")
143 view.Send()
144 blocks := promptBlocks(t, agent.read())
145 if len(blocks) != 1 || blocks[0]["text"] != "a b" {
146 t.Errorf("the agent received %v, want just %q", blocks, "a b")
147 }
148}
149
150func TestDeleteAtTheStartOfATokenRemovesItWhole(t *testing.T) {
151 view, _ := newView(t, 60, 12)
152 view.Paste(trace)
153 typeInto(view, "!")
154 view.HandleKey(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone))
155 view.HandleKey(tcell.NewEventKey(tcell.KeyDelete, 0, tcell.ModNone))
156
157 if got := view.Input(); got != "!" {
158 t.Errorf("Input() = %q after Delete at the token's start", got)
159 }
160}
161
162func TestTheArrowKeysStepOverATokenRatherThanInto(t *testing.T) {
163 view, _ := newView(t, 60, 12)
164 view.Paste(trace)
165 typeInto(view, "x")
166
167 view.HandleKey(tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)) // before x
168 view.HandleKey(tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)) // over the token
169 typeInto(view, "y")
170 view.HandleKey(tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)) // over it again
171 typeInto(view, "z")
172
173 if got := view.Input(); got != "y[Pasted #1 · 6 lines · 151 chars]zx" {
174 t.Errorf("Input() = %q", got)
175 }
176}
177
178func TestAMoveByRowThatLandsInsideATokenIsPushedToItsEdge(t *testing.T) {
179 // Only ↑/↓ can land inside one; typing into the middle of a token would
180 // break it into text that stands for nothing.
181 view, _ := newView(t, 40, 12) // rows of 37 runes
182 typeInto(view, strings.Repeat("a", 30)+" ")
183 view.Paste(trace) // the 34-rune token wraps onto the second row
184 view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone))
185 view.HandleKey(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone))
186 view.HandleKey(tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone))
187 view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)) // column 1 of the token's row
188 typeInto(view, "!")
189
190 got := view.Input()
191 if !strings.Contains(got, "![Pasted") && !strings.Contains(got, "chars]!") {
192 t.Errorf("Input() = %q, want the ! at an edge of the token", got)
193 }
194}
195
196func TestATokenIsDrawnInItsOwnColour(t *testing.T) {
197 view, _ := newView(t, 60, 12)
198 typeInto(view, "see ")
199 view.Paste(trace)
200
201 screen := tcell.NewSimulationScreen("UTF-8")
202 if err := screen.Init(); err != nil {
203 t.Fatal(err)
204 }
205 defer screen.Fini()
206 screen.SetSize(60, 12)
207 th, err := theme.Load(theme.DefaultName, "")
208 if err != nil {
209 t.Fatal(err)
210 }
211 view.Draw(ui.NewPainter(screen), th)
212 screen.Show()
213
214 cells, w, _ := screen.GetContents()
215 row := 9 // the box's first row
216 word, token := cells[row*w+2].Style, cells[row*w+2+len("see ")].Style
217 if word == token {
218 t.Error("the token is drawn in the same colour as the words around it")
219 }
220 if got := string(cells[row*w+2+len("see ")].Runes); got != "[" {
221 t.Errorf("the token's first cell shows %q, want its bracket", got)
222 }
223}
224
225func TestCtrlVPastesWhatTheEditorsClipboardHolds(t *testing.T) {
226 view, _ := newView(t, 60, 12)
227 view.OnPaste = func() string { return "one\ntwo\nthree" }
228
229 view.HandleKey(tcell.NewEventKey(tcell.KeyCtrlV, 0, tcell.ModNone))
230
231 if got := view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" {
232 t.Errorf("Input() = %q after Ctrl-V", got)
233 }
234}
235
236func TestShiftInsertPastesToo(t *testing.T) {
237 view, _ := newView(t, 60, 12)
238 view.OnPaste = func() string { return "short" }
239
240 view.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModShift))
241
242 if got := view.Input(); got != "short" {
243 t.Errorf("Input() = %q after Shift-Ins", got)
244 }
245}
246
247func TestPasteNumbersRunOnAcrossPrompts(t *testing.T) {
248 // "#1" in an earlier exchange is not the same thing as "#1" in this one.
249 view, agent := mentioningView(t, nil)
250 agent.handshake()
251 waitFor(t, "ready", view.Session().Ready)
252
253 view.Paste(trace)
254 view.Send()
255 agent.read()
256 view.Paste(trace)
257
258 if got := view.Input(); !strings.HasPrefix(got, "[Pasted #2 ") {
259 t.Errorf("the second paste is %q, want #2", got)
260 }
261}
262
263func TestAPasteGoesIntoTheBoxWhicheverPaneHadTheFocus(t *testing.T) {
264 view, _ := newView(t, 60, 12)
265 view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) // to the conversation
266
267 view.Paste("hello")
268 typeInto(view, "!")
269
270 if got := view.Input(); got != "hello!" {
271 t.Errorf("Input() = %q, want the paste and the keystroke after it in the box", got)
272 }
273}