| 📦 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 ago | 1 | package acp_test |
| 2 | |
| 3 | import ( |
| 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. |
| 16 | const 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 | |
| 23 | func 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 | |
| 37 | func 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 | |
| 48 | func 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 | |
| 57 | func 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 | |
| 66 | func 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 | |
| 91 | func 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 | |
| 108 | func 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 | |
| 126 | func 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 | |
| 150 | func 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 | |
| 162 | func 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 | |
| 178 | func 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 | |
| 196 | func 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 | |
| 225 | func 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 | |
| 236 | func 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 | |
| 247 | func 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 | |
| 263 | func 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 | } |