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.

view_events.go · 346 lines · 9.5 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g yesterday1package acp
2
3import "github.com/gdamore/tcell/v2"
4
5// HandleKey routes a key to whichever half of the window has the focus.
6//
7// An agent window does not take the editor's own shortcuts the way a terminal
8// does. There is no shell here needing Ctrl-C, Ctrl-W or Ctrl-F, so those keep
9// their usual meanings and only the keys this window really uses are claimed.
10func (v *View) HandleKey(ev *tcell.EventKey) bool {
11 before := v.Input()
12 defer func() {
13 if v.Input() != before {
14 v.picker.dismissed = false // Escape's "not now" lasts until the text moves on
15 }
16 }()
17
18 if v.handlePickerKey(ev) {
19 return true
20 }
21 if handled, claimed := v.handleWindowKey(ev); claimed {
22 return handled
23 }
24 if v.onInput && v.handleInputKey(ev) {
25 return true
26 }
27 return v.handleScrollKey(ev)
28}
29
30// handleWindowKey deals with the keys that mean the same thing whichever pane
31// has the focus, and says whether it claimed the key at all.
32//
33// Two results rather than one: Escape is claimed by this window only when
34// there is a selection to drop or a turn to stop, and "claimed but did
35// nothing" has to be told apart from "not mine" — otherwise Escape would
36// either be swallowed always or reach the input pane and be typed.
37func (v *View) handleWindowKey(ev *tcell.EventKey) (handled, claimed bool) {
38 switch {
39 case ev.Key() == tcell.KeyTab:
40 v.onInput = !v.onInput
41 case ev.Key() == tcell.KeyCtrlC, isCopyKey(ev):
42 return v.copySelection(), true
📦 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 ago43 case ev.Key() == tcell.KeyCtrlV, isPasteKey(ev):
44 return v.pasteClipboard(), true
🛟 Updated. 28d5985 k33g yesterday45 case ev.Key() == tcell.KeyEscape:
46 return v.clearOrCancel(), true
47 case ev.Key() == tcell.KeyEnter && ev.Modifiers()&tcell.ModAlt != 0:
48 v.insertNewline()
49 case ev.Key() == tcell.KeyEnter:
50 v.Send()
51 default:
52 return false, false
53 }
54 return true, true
55}
56
57// isCopyKey reports whether a key is the editor's own Copy — Ctrl-Ins, as the
58// Edit menu says. Ctrl-C is accepted beside it because nothing in an agent
59// window wants Ctrl-C for anything else, and it is the key most people reach
60// for.
61func isCopyKey(ev *tcell.EventKey) bool {
62 return ev.Key() == tcell.KeyInsert && ev.Modifiers()&tcell.ModCtrl != 0
63}
64
65// clearOrCancel makes Escape mean the nearest thing first: drop the selection
66// if there is one, otherwise stop the turn.
67//
68// Two meanings on one key, ordered by how local they are. A selection is
69// something you just made and can see; a turn is the window's whole state. The
70// key is left alone when there is neither, so Escape keeps whatever meaning the
71// rest of the editor gives it.
72func (v *View) clearOrCancel() bool {
73 if _, _, selected := v.Selection(); selected {
74 v.SelectNothing()
75 return true
76 }
77 return v.cancelTurn()
78}
79
80// cancelTurn stops the turn in progress, and reports whether there was one.
81func (v *View) cancelTurn() bool {
82 if !v.session.Running() {
83 return false
84 }
85 v.session.Cancel()
86 return true
87}
88
89// handleInputKey edits what is being typed.
90func (v *View) handleInputKey(ev *tcell.EventKey) bool {
91 switch ev.Key() {
92 case tcell.KeyRune:
93 v.insert(ev.Rune())
94 case tcell.KeyBackspace, tcell.KeyBackspace2:
95 v.backspace()
96 case tcell.KeyDelete:
97 v.delete()
98 case tcell.KeyLeft:
99 v.moveLeft()
100 case tcell.KeyRight:
101 v.moveRight()
102 case tcell.KeyHome:
103 v.column = 0
104 case tcell.KeyEnd:
105 v.column = len(v.runes())
106 case tcell.KeyUp:
📦 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 ago107 return v.moveInputRow(-1) // off the top, it moves through the conversation instead
🛟 Updated. 28d5985 k33g yesterday108 case tcell.KeyDown:
📦 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 ago109 return v.moveInputRow(1)
🛟 Updated. 28d5985 k33g yesterday110 default:
111 return false
112 }
113 return true
114}
115
116// handleScrollKey moves the cursor through the conversation, scrolling to
117// follow it, and extends the selection when Shift is held.
118func (v *View) handleScrollKey(ev *tcell.EventKey) bool {
119 page := max(v.transcriptHeight()-1, 1)
120 extend := ev.Modifiers()&tcell.ModShift != 0
121
122 switch ev.Key() {
123 case tcell.KeyUp:
124 v.moveCaret(v.caret-1, extend)
125 case tcell.KeyDown:
126 v.moveCaret(v.caret+1, extend)
127 case tcell.KeyPgUp:
128 v.moveCaret(v.caret-page, extend)
129 case tcell.KeyPgDn:
130 v.moveCaret(v.caret+page, extend)
131 case tcell.KeyHome:
132 v.moveCaret(0, extend)
133 case tcell.KeyEnd:
134 v.moveCaret(v.laidOut-1, extend)
135 v.follow = true
136 default:
137 return false
138 }
139 return true
140}
141
142// HandleMouse scrolls the conversation with the wheel, moves the focus to
143// whichever half was clicked, and selects lines by dragging.
144func (v *View) HandleMouse(ev *tcell.EventMouse) bool {
145 x, y := ev.Position()
146 if !v.Bounds().Contains(x, y) {
147 v.dragging = false
148 return false
149 }
150
151 switch ev.Buttons() {
152 case tcell.WheelUp:
153 v.scrollBy(-3)
154 case tcell.WheelDown:
155 v.scrollBy(3)
156 case tcell.Button1:
157 if !v.clickPick(x, y) {
158 v.pressOrDrag(x, y)
159 }
160 case tcell.ButtonNone:
161 v.dragging = false
162 return false
163 default:
164 return false
165 }
166 return true
167}
168
169// pressOrDrag starts a selection on the first event and extends it on the rest.
170//
171// tcell sends Button1 for every event of a drag, not only the first, so "is
172// this a new press?" is a thing this has to remember — the same trap the
173// editor's double-click counting had to work around.
174func (v *View) pressOrDrag(x, y int) {
175 if !v.transcriptRect().Contains(x, y) {
176 v.onInput = true
177 v.dragging = false
178 return
179 }
180
181 v.onInput = false
182 at := v.scroll + (y - v.transcriptRect().Y)
183
184 v.moveCaret(at, v.dragging)
185 v.dragging = true
186}
187
188// insert puts a rune where the cursor is.
189func (v *View) insert(r rune) {
190 runes := v.runes()
191 v.column = min(v.column, len(runes))
192
193 updated := append([]rune{}, runes[:v.column]...)
194 updated = append(updated, r)
195 updated = append(updated, runes[v.column:]...)
196
197 v.input[v.cursor] = string(updated)
198 v.column++
199}
200
201// insertNewline breaks the line the cursor is on, which is how a prompt gets a
202// second paragraph without sending the first.
203func (v *View) insertNewline() {
204 runes := v.runes()
205 v.column = min(v.column, len(runes))
206
207 before, after := string(runes[:v.column]), string(runes[v.column:])
208 v.input[v.cursor] = before
209 v.input = append(v.input, "")
210 copy(v.input[v.cursor+2:], v.input[v.cursor+1:])
211 v.input[v.cursor+1] = after
212
213 v.cursor++
214 v.column = 0
215}
216
217// backspace removes the rune before the cursor, joining lines at a line start.
📦 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 ago218// Before a paste token it removes the whole token: the token is one thing,
219// and a token missing its last bracket stands for nothing.
🛟 Updated. 28d5985 k33g yesterday220func (v *View) backspace() {
📦 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 ago221 if span, ok := v.tokenEndingAt(v.column); ok {
222 v.removeSpan(span)
223 return
224 }
🛟 Updated. 28d5985 k33g yesterday225 if v.column > 0 {
226 runes := v.runes()
227 v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column-1]...), runes[v.column:]...))
228 v.column--
229 return
230 }
231 if v.cursor == 0 {
232 return
233 }
234
235 above := []rune(v.input[v.cursor-1])
236 v.column = len(above)
237 v.input[v.cursor-1] = string(above) + v.input[v.cursor]
238 v.input = append(v.input[:v.cursor], v.input[v.cursor+1:]...)
239 v.cursor--
240}
241
📦 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 ago242// delete removes the rune under the cursor — or the whole paste token that
243// starts there, for the reason backspace gives.
🛟 Updated. 28d5985 k33g yesterday244func (v *View) delete() {
📦 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 ago245 if span, ok := v.tokenStartingAt(v.column); ok {
246 v.removeSpan(span)
247 return
248 }
🛟 Updated. 28d5985 k33g yesterday249 runes := v.runes()
250 if v.column >= len(runes) {
251 return
252 }
253 v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column]...), runes[v.column+1:]...))
254}
255
📦 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 ago256// moveLeft moves back one rune — over a whole paste token — onto the end of
257// the line above at a line start.
🛟 Updated. 28d5985 k33g yesterday258func (v *View) moveLeft() {
📦 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 ago259 if span, ok := v.tokenEndingAt(v.column); ok {
260 v.column = span.start
261 return
262 }
🛟 Updated. 28d5985 k33g yesterday263 switch {
264 case v.column > 0:
265 v.column--
266 case v.cursor > 0:
267 v.cursor--
268 v.column = len(v.runes())
269 }
270}
271
📦 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 ago272// moveRight moves on one rune — over a whole paste token — onto the start of
273// the next line at a line end.
🛟 Updated. 28d5985 k33g yesterday274func (v *View) moveRight() {
📦 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 ago275 if span, ok := v.tokenStartingAt(v.column); ok {
276 v.column = span.end
277 return
278 }
🛟 Updated. 28d5985 k33g yesterday279 switch {
280 case v.column < len(v.runes()):
281 v.column++
282 case v.cursor < len(v.input)-1:
283 v.cursor++
284 v.column = 0
285 }
286}
287
288// runes is the line the cursor is on.
289func (v *View) runes() []rune {
290 if v.cursor >= len(v.input) {
291 return nil
292 }
293 return []rune(v.input[v.cursor])
294}
295
296// scrollBy moves through the conversation, and stops following the end as soon
297// as somebody scrolls up — reading what happened is not interrupted by the
298// agent still writing.
299func (v *View) scrollBy(by int) {
300 v.scrollTo(v.scroll + by)
301}
302
303// scrollTo moves to a line, taking up following again at the very end.
304func (v *View) scrollTo(at int) {
305 height := max(v.transcriptHeight(), 1)
306 v.scroll = max(at, 0)
307 v.follow = v.scroll >= max(v.laidOut-height, 0)
308}
309
310// clampScroll keeps the view inside the conversation, and pins it to the end
311// while it is following.
312//
313// It runs at draw time because that is the only moment the laid-out height is
314// known: the conversation is re-wrapped at the current width every frame, so
315// there is no stored total to clamp against beforehand.
316func (v *View) clampScroll(height, total int) {
317 last := max(total-height, 0)
318 if v.follow {
319 v.scroll = last
320 return
321 }
322 v.scroll = min(max(v.scroll, 0), last)
323}
324
325// Following reports whether the window is pinned to the end of the
326// conversation.
327func (v *View) Following() bool { return v.follow }
328
329// Title returns what the window is called: the agent, and what it is doing.
330func (v *View) Title() string {
331 name := v.session.Agent().Name
332 switch {
333 case v.session.Err() != nil:
334 return name + " — stopped"
335 case !v.session.Ready():
336 return name + " — starting"
337 case v.session.Running():
338 // No spinner here. The title is also what the window list and the
339 // Alt-digit menu show, and a name that changes eight times a second
340 // makes both of them flicker. The rule inside the window is where it
341 // animates.
342 return name + " — thinking"
343 default:
344 return name
345 }
346}