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.

view_events.go · 334 lines · 9.0 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 17h ago1package 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
43 case ev.Key() == tcell.KeyEscape:
44 return v.clearOrCancel(), true
45 case ev.Key() == tcell.KeyEnter && ev.Modifiers()&tcell.ModAlt != 0:
46 v.insertNewline()
47 case ev.Key() == tcell.KeyEnter:
48 v.Send()
49 default:
50 return false, false
51 }
52 return true, true
53}
54
55// isCopyKey reports whether a key is the editor's own Copy — Ctrl-Ins, as the
56// Edit menu says. Ctrl-C is accepted beside it because nothing in an agent
57// window wants Ctrl-C for anything else, and it is the key most people reach
58// for.
59func isCopyKey(ev *tcell.EventKey) bool {
60 return ev.Key() == tcell.KeyInsert && ev.Modifiers()&tcell.ModCtrl != 0
61}
62
63// clearOrCancel makes Escape mean the nearest thing first: drop the selection
64// if there is one, otherwise stop the turn.
65//
66// Two meanings on one key, ordered by how local they are. A selection is
67// something you just made and can see; a turn is the window's whole state. The
68// key is left alone when there is neither, so Escape keeps whatever meaning the
69// rest of the editor gives it.
70func (v *View) clearOrCancel() bool {
71 if _, _, selected := v.Selection(); selected {
72 v.SelectNothing()
73 return true
74 }
75 return v.cancelTurn()
76}
77
78// cancelTurn stops the turn in progress, and reports whether there was one.
79func (v *View) cancelTurn() bool {
80 if !v.session.Running() {
81 return false
82 }
83 v.session.Cancel()
84 return true
85}
86
87// handleInputKey edits what is being typed.
88func (v *View) handleInputKey(ev *tcell.EventKey) bool {
89 switch ev.Key() {
90 case tcell.KeyRune:
91 v.insert(ev.Rune())
92 case tcell.KeyBackspace, tcell.KeyBackspace2:
93 v.backspace()
94 case tcell.KeyDelete:
95 v.delete()
96 case tcell.KeyLeft:
97 v.moveLeft()
98 case tcell.KeyRight:
99 v.moveRight()
100 case tcell.KeyHome:
101 v.column = 0
102 case tcell.KeyEnd:
103 v.column = len(v.runes())
104 case tcell.KeyUp:
105 if v.cursor == 0 {
106 return false // let it move through the conversation instead
107 }
108 v.cursor--
109 v.clampColumn()
110 case tcell.KeyDown:
111 if v.cursor >= len(v.input)-1 {
112 return false
113 }
114 v.cursor++
115 v.clampColumn()
116 default:
117 return false
118 }
119 return true
120}
121
122// handleScrollKey moves the cursor through the conversation, scrolling to
123// follow it, and extends the selection when Shift is held.
124func (v *View) handleScrollKey(ev *tcell.EventKey) bool {
125 page := max(v.transcriptHeight()-1, 1)
126 extend := ev.Modifiers()&tcell.ModShift != 0
127
128 switch ev.Key() {
129 case tcell.KeyUp:
130 v.moveCaret(v.caret-1, extend)
131 case tcell.KeyDown:
132 v.moveCaret(v.caret+1, extend)
133 case tcell.KeyPgUp:
134 v.moveCaret(v.caret-page, extend)
135 case tcell.KeyPgDn:
136 v.moveCaret(v.caret+page, extend)
137 case tcell.KeyHome:
138 v.moveCaret(0, extend)
139 case tcell.KeyEnd:
140 v.moveCaret(v.laidOut-1, extend)
141 v.follow = true
142 default:
143 return false
144 }
145 return true
146}
147
148// HandleMouse scrolls the conversation with the wheel, moves the focus to
149// whichever half was clicked, and selects lines by dragging.
150func (v *View) HandleMouse(ev *tcell.EventMouse) bool {
151 x, y := ev.Position()
152 if !v.Bounds().Contains(x, y) {
153 v.dragging = false
154 return false
155 }
156
157 switch ev.Buttons() {
158 case tcell.WheelUp:
159 v.scrollBy(-3)
160 case tcell.WheelDown:
161 v.scrollBy(3)
162 case tcell.Button1:
163 if !v.clickPick(x, y) {
164 v.pressOrDrag(x, y)
165 }
166 case tcell.ButtonNone:
167 v.dragging = false
168 return false
169 default:
170 return false
171 }
172 return true
173}
174
175// pressOrDrag starts a selection on the first event and extends it on the rest.
176//
177// tcell sends Button1 for every event of a drag, not only the first, so "is
178// this a new press?" is a thing this has to remember — the same trap the
179// editor's double-click counting had to work around.
180func (v *View) pressOrDrag(x, y int) {
181 if !v.transcriptRect().Contains(x, y) {
182 v.onInput = true
183 v.dragging = false
184 return
185 }
186
187 v.onInput = false
188 at := v.scroll + (y - v.transcriptRect().Y)
189
190 v.moveCaret(at, v.dragging)
191 v.dragging = true
192}
193
194// insert puts a rune where the cursor is.
195func (v *View) insert(r rune) {
196 runes := v.runes()
197 v.column = min(v.column, len(runes))
198
199 updated := append([]rune{}, runes[:v.column]...)
200 updated = append(updated, r)
201 updated = append(updated, runes[v.column:]...)
202
203 v.input[v.cursor] = string(updated)
204 v.column++
205}
206
207// insertNewline breaks the line the cursor is on, which is how a prompt gets a
208// second paragraph without sending the first.
209func (v *View) insertNewline() {
210 runes := v.runes()
211 v.column = min(v.column, len(runes))
212
213 before, after := string(runes[:v.column]), string(runes[v.column:])
214 v.input[v.cursor] = before
215 v.input = append(v.input, "")
216 copy(v.input[v.cursor+2:], v.input[v.cursor+1:])
217 v.input[v.cursor+1] = after
218
219 v.cursor++
220 v.column = 0
221}
222
223// backspace removes the rune before the cursor, joining lines at a line start.
224func (v *View) backspace() {
225 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
242// delete removes the rune under the cursor.
243func (v *View) delete() {
244 runes := v.runes()
245 if v.column >= len(runes) {
246 return
247 }
248 v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column]...), runes[v.column+1:]...))
249}
250
251// moveLeft moves back one rune, onto the end of the line above at a line start.
252func (v *View) moveLeft() {
253 switch {
254 case v.column > 0:
255 v.column--
256 case v.cursor > 0:
257 v.cursor--
258 v.column = len(v.runes())
259 }
260}
261
262// moveRight moves on one rune, onto the start of the next line at a line end.
263func (v *View) moveRight() {
264 switch {
265 case v.column < len(v.runes()):
266 v.column++
267 case v.cursor < len(v.input)-1:
268 v.cursor++
269 v.column = 0
270 }
271}
272
273// runes is the line the cursor is on.
274func (v *View) runes() []rune {
275 if v.cursor >= len(v.input) {
276 return nil
277 }
278 return []rune(v.input[v.cursor])
279}
280
281// clampColumn keeps the cursor inside the line it moved onto.
282func (v *View) clampColumn() { v.column = min(v.column, len(v.runes())) }
283
284// scrollBy moves through the conversation, and stops following the end as soon
285// as somebody scrolls up — reading what happened is not interrupted by the
286// agent still writing.
287func (v *View) scrollBy(by int) {
288 v.scrollTo(v.scroll + by)
289}
290
291// scrollTo moves to a line, taking up following again at the very end.
292func (v *View) scrollTo(at int) {
293 height := max(v.transcriptHeight(), 1)
294 v.scroll = max(at, 0)
295 v.follow = v.scroll >= max(v.laidOut-height, 0)
296}
297
298// clampScroll keeps the view inside the conversation, and pins it to the end
299// while it is following.
300//
301// It runs at draw time because that is the only moment the laid-out height is
302// known: the conversation is re-wrapped at the current width every frame, so
303// there is no stored total to clamp against beforehand.
304func (v *View) clampScroll(height, total int) {
305 last := max(total-height, 0)
306 if v.follow {
307 v.scroll = last
308 return
309 }
310 v.scroll = min(max(v.scroll, 0), last)
311}
312
313// Following reports whether the window is pinned to the end of the
314// conversation.
315func (v *View) Following() bool { return v.follow }
316
317// Title returns what the window is called: the agent, and what it is doing.
318func (v *View) Title() string {
319 name := v.session.Agent().Name
320 switch {
321 case v.session.Err() != nil:
322 return name + " — stopped"
323 case !v.session.Ready():
324 return name + " — starting"
325 case v.session.Running():
326 // No spinner here. The title is also what the window list and the
327 // Alt-digit menu show, and a name that changes eight times a second
328 // makes both of them flicker. The rule inside the window is where it
329 // animates.
330 return name + " — thinking"
331 default:
332 return name
333 }
334}