package acp import "github.com/gdamore/tcell/v2" // HandleKey routes a key to whichever half of the window has the focus. // // An agent window does not take the editor's own shortcuts the way a terminal // does. There is no shell here needing Ctrl-C, Ctrl-W or Ctrl-F, so those keep // their usual meanings and only the keys this window really uses are claimed. func (v *View) HandleKey(ev *tcell.EventKey) bool { before := v.Input() defer func() { if v.Input() != before { v.picker.dismissed = false // Escape's "not now" lasts until the text moves on } }() if v.handlePickerKey(ev) { return true } if handled, claimed := v.handleWindowKey(ev); claimed { return handled } if v.onInput && v.handleInputKey(ev) { return true } return v.handleScrollKey(ev) } // handleWindowKey deals with the keys that mean the same thing whichever pane // has the focus, and says whether it claimed the key at all. // // Two results rather than one: Escape is claimed by this window only when // there is a selection to drop or a turn to stop, and "claimed but did // nothing" has to be told apart from "not mine" — otherwise Escape would // either be swallowed always or reach the input pane and be typed. func (v *View) handleWindowKey(ev *tcell.EventKey) (handled, claimed bool) { switch { case ev.Key() == tcell.KeyTab: v.onInput = !v.onInput case ev.Key() == tcell.KeyCtrlC, isCopyKey(ev): return v.copySelection(), true case ev.Key() == tcell.KeyEscape: return v.clearOrCancel(), true case ev.Key() == tcell.KeyEnter && ev.Modifiers()&tcell.ModAlt != 0: v.insertNewline() case ev.Key() == tcell.KeyEnter: v.Send() default: return false, false } return true, true } // isCopyKey reports whether a key is the editor's own Copy — Ctrl-Ins, as the // Edit menu says. Ctrl-C is accepted beside it because nothing in an agent // window wants Ctrl-C for anything else, and it is the key most people reach // for. func isCopyKey(ev *tcell.EventKey) bool { return ev.Key() == tcell.KeyInsert && ev.Modifiers()&tcell.ModCtrl != 0 } // clearOrCancel makes Escape mean the nearest thing first: drop the selection // if there is one, otherwise stop the turn. // // Two meanings on one key, ordered by how local they are. A selection is // something you just made and can see; a turn is the window's whole state. The // key is left alone when there is neither, so Escape keeps whatever meaning the // rest of the editor gives it. func (v *View) clearOrCancel() bool { if _, _, selected := v.Selection(); selected { v.SelectNothing() return true } return v.cancelTurn() } // cancelTurn stops the turn in progress, and reports whether there was one. func (v *View) cancelTurn() bool { if !v.session.Running() { return false } v.session.Cancel() return true } // handleInputKey edits what is being typed. func (v *View) handleInputKey(ev *tcell.EventKey) bool { switch ev.Key() { case tcell.KeyRune: v.insert(ev.Rune()) case tcell.KeyBackspace, tcell.KeyBackspace2: v.backspace() case tcell.KeyDelete: v.delete() case tcell.KeyLeft: v.moveLeft() case tcell.KeyRight: v.moveRight() case tcell.KeyHome: v.column = 0 case tcell.KeyEnd: v.column = len(v.runes()) case tcell.KeyUp: if v.cursor == 0 { return false // let it move through the conversation instead } v.cursor-- v.clampColumn() case tcell.KeyDown: if v.cursor >= len(v.input)-1 { return false } v.cursor++ v.clampColumn() default: return false } return true } // handleScrollKey moves the cursor through the conversation, scrolling to // follow it, and extends the selection when Shift is held. func (v *View) handleScrollKey(ev *tcell.EventKey) bool { page := max(v.transcriptHeight()-1, 1) extend := ev.Modifiers()&tcell.ModShift != 0 switch ev.Key() { case tcell.KeyUp: v.moveCaret(v.caret-1, extend) case tcell.KeyDown: v.moveCaret(v.caret+1, extend) case tcell.KeyPgUp: v.moveCaret(v.caret-page, extend) case tcell.KeyPgDn: v.moveCaret(v.caret+page, extend) case tcell.KeyHome: v.moveCaret(0, extend) case tcell.KeyEnd: v.moveCaret(v.laidOut-1, extend) v.follow = true default: return false } return true } // HandleMouse scrolls the conversation with the wheel, moves the focus to // whichever half was clicked, and selects lines by dragging. func (v *View) HandleMouse(ev *tcell.EventMouse) bool { x, y := ev.Position() if !v.Bounds().Contains(x, y) { v.dragging = false return false } switch ev.Buttons() { case tcell.WheelUp: v.scrollBy(-3) case tcell.WheelDown: v.scrollBy(3) case tcell.Button1: if !v.clickPick(x, y) { v.pressOrDrag(x, y) } case tcell.ButtonNone: v.dragging = false return false default: return false } return true } // pressOrDrag starts a selection on the first event and extends it on the rest. // // tcell sends Button1 for every event of a drag, not only the first, so "is // this a new press?" is a thing this has to remember — the same trap the // editor's double-click counting had to work around. func (v *View) pressOrDrag(x, y int) { if !v.transcriptRect().Contains(x, y) { v.onInput = true v.dragging = false return } v.onInput = false at := v.scroll + (y - v.transcriptRect().Y) v.moveCaret(at, v.dragging) v.dragging = true } // insert puts a rune where the cursor is. func (v *View) insert(r rune) { runes := v.runes() v.column = min(v.column, len(runes)) updated := append([]rune{}, runes[:v.column]...) updated = append(updated, r) updated = append(updated, runes[v.column:]...) v.input[v.cursor] = string(updated) v.column++ } // insertNewline breaks the line the cursor is on, which is how a prompt gets a // second paragraph without sending the first. func (v *View) insertNewline() { runes := v.runes() v.column = min(v.column, len(runes)) before, after := string(runes[:v.column]), string(runes[v.column:]) v.input[v.cursor] = before v.input = append(v.input, "") copy(v.input[v.cursor+2:], v.input[v.cursor+1:]) v.input[v.cursor+1] = after v.cursor++ v.column = 0 } // backspace removes the rune before the cursor, joining lines at a line start. func (v *View) backspace() { if v.column > 0 { runes := v.runes() v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column-1]...), runes[v.column:]...)) v.column-- return } if v.cursor == 0 { return } above := []rune(v.input[v.cursor-1]) v.column = len(above) v.input[v.cursor-1] = string(above) + v.input[v.cursor] v.input = append(v.input[:v.cursor], v.input[v.cursor+1:]...) v.cursor-- } // delete removes the rune under the cursor. func (v *View) delete() { runes := v.runes() if v.column >= len(runes) { return } v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column]...), runes[v.column+1:]...)) } // moveLeft moves back one rune, onto the end of the line above at a line start. func (v *View) moveLeft() { switch { case v.column > 0: v.column-- case v.cursor > 0: v.cursor-- v.column = len(v.runes()) } } // moveRight moves on one rune, onto the start of the next line at a line end. func (v *View) moveRight() { switch { case v.column < len(v.runes()): v.column++ case v.cursor < len(v.input)-1: v.cursor++ v.column = 0 } } // runes is the line the cursor is on. func (v *View) runes() []rune { if v.cursor >= len(v.input) { return nil } return []rune(v.input[v.cursor]) } // clampColumn keeps the cursor inside the line it moved onto. func (v *View) clampColumn() { v.column = min(v.column, len(v.runes())) } // scrollBy moves through the conversation, and stops following the end as soon // as somebody scrolls up — reading what happened is not interrupted by the // agent still writing. func (v *View) scrollBy(by int) { v.scrollTo(v.scroll + by) } // scrollTo moves to a line, taking up following again at the very end. func (v *View) scrollTo(at int) { height := max(v.transcriptHeight(), 1) v.scroll = max(at, 0) v.follow = v.scroll >= max(v.laidOut-height, 0) } // clampScroll keeps the view inside the conversation, and pins it to the end // while it is following. // // It runs at draw time because that is the only moment the laid-out height is // known: the conversation is re-wrapped at the current width every frame, so // there is no stored total to clamp against beforehand. func (v *View) clampScroll(height, total int) { last := max(total-height, 0) if v.follow { v.scroll = last return } v.scroll = min(max(v.scroll, 0), last) } // Following reports whether the window is pinned to the end of the // conversation. func (v *View) Following() bool { return v.follow } // Title returns what the window is called: the agent, and what it is doing. func (v *View) Title() string { name := v.session.Agent().Name switch { case v.session.Err() != nil: return name + " — stopped" case !v.session.Ready(): return name + " — starting" case v.session.Running(): // No spinner here. The title is also what the window list and the // Alt-digit menu show, and a name that changes eight times a second // makes both of them flicker. The rule inside the window is where it // animates. return name + " — thinking" default: return name } }