package tui import ( "time" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "harness/internal/llm" ) // The mouse is handled here the way keys are handled in onKey: one entry // point, the piece of the screen under the pointer decides who answers. // // Everything a terminal would normally do with a drag it stops doing the // moment mouse reporting is asked for, so what this file adds is not only new // behaviour — the wheel, clickable chrome — but also the replacement for what // enabling the mouse takes away. /mouse gives it all back. // doubleClick is how close two presses on the same cell have to be to count // as a double-click. 400ms is the middle of what desktops use. const doubleClick = 400 * time.Millisecond // sectionKind names a piece of the screen. The transcript is one of them so // that a hit test always has an answer. type sectionKind int const ( secTranscript sectionKind = iota secActivity secInput secMenu secStatus secMode secApproval secEffort secModel ) // section is one piece of the bottom chrome and what it rendered to. The // layout is built from these rather than from a tally of heights kept // somewhere else, so where a click lands and what was drawn cannot disagree. type section struct { kind sectionKind text string } // layout is the bottom chrome, in order. It is what bottom() joins together // and what a mouse event is resolved against, which is the point: a click // finds the same rows the frame drew. func (m *model) layout() []section { secs := []section{{secActivity, m.activity()}} switch { case m.phase == phaseApproving && len(m.pending) > 0: secs = append(secs, section{secApproval, m.approvalView()}) case m.phase == phaseEffort: secs = append(secs, section{secEffort, m.effortView()}) case m.phase == phaseModel: secs = append(secs, section{secModel, m.modelView()}) default: secs = append(secs, section{secInput, m.inputBox()}) if v := m.menuView(); v != "" { secs = append(secs, section{secMenu, v}) } } return append(secs, section{secStatus, m.status()}, section{secMode, m.modeLine()}) } // chromeRows is how many rows the bottom chrome takes, and so where the // transcript ends. func (m *model) chromeRows() int { return rowsOf(m.layout()) } func rowsOf(secs []section) int { n := 0 for _, s := range secs { n += lipgloss.Height(s.text) } return n } // hitIn says what is at screen row y: which piece of the screen, and which of // that piece's own rows. top is the first row the chrome occupies, which is // also how many rows the transcript has. func hitIn(secs []section, top, y int) (sectionKind, int) { if y < top { return secTranscript, y } for _, s := range secs { n := lipgloss.Height(s.text) if y < top+n { return s.kind, y - top } top += n } return secTranscript, y } func (m *model) onMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // The wizard owns the whole screen, and it is keyboard-driven; the chrome // underneath it is not on screen to be clicked. if !m.mouseOn || m.wiz != nil { return m, nil } // The screen is measured once per event rather than once per question // asked about it: laying the chrome out is cheap, but a drag asks several // times a frame. secs := m.layout() rows := maxInt(1, m.height-rowsOf(secs)) kind, row := hitIn(secs, rows, msg.Y) // A drag that has wandered into the chrome is still that drag. Following // it past the edge of the transcript is what lets a selection run further // than one screenful. if m.sel.drag && msg.Action != tea.MouseActionPress { kind, row = secTranscript, minInt(row, rows-1) } if kind == secTranscript { return m, m.onTranscriptMouse(msg, row, rows) } if isWheel(msg) { return m, m.onChromeWheel(kind, msg) } if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft { return m, nil } return m.onChromeClick(kind, row, msg.X) } // onTranscriptMouse is the scrollback: the wheel rolls it, a drag marks text // on it. func (m *model) onTranscriptMouse(msg tea.MouseMsg, row, rows int) tea.Cmd { if isWheel(msg) { var cmd tea.Cmd m.vp, cmd = m.vp.Update(msg) return cmd } switch { case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft: return m.pressTranscript(row, msg.X, msg.Shift) case msg.Action == tea.MouseActionMotion && m.sel.drag: // Dragging against either edge scrolls, so a selection can be longer // than the screen. Movement is what keeps it going: with button-event // reporting a hand held still at the edge sends nothing at all. switch { case row <= 0: m.vp.ScrollUp(1) case row >= rows-1: m.vp.ScrollDown(1) } m.sel.to(m.cellAt(row, msg.X)) return nil case msg.Action == tea.MouseActionRelease: if !m.sel.drag { return nil } m.sel.release() if !m.sel.on { // A press and a release with nothing in between is a click, which // is how a selection is dismissed. m.clearSelection() return nil } return m.copySelection() } return nil } // pressTranscript starts a selection, or widens the one a second and third // click on the same cell are asking for. func (m *model) pressTranscript(row, x int, shift bool) tea.Cmd { c := m.cellAt(row, x) now := time.Now() if c == m.clickAt && now.Sub(m.clickTime) < doubleClick { m.clicks++ } else { m.clicks = 1 } m.clickAt, m.clickTime = c, now m.copied = "" m.sel.press(c, shift) switch m.clicks { case 2: m.sel.widen(selWord) case 3: m.sel.widen(selLine) default: return nil } // A double-click is a whole gesture on its own: there is no release to // wait for before the word is there to be taken. return m.copySelection() } // cellAt is which piece of the transcript is under the pointer. A column past // the end of a line clamps to it, so dragging through the empty right-hand // side of the screen selects to the end of the text rather than to nothing. func (m *model) cellAt(row, x int) cell { lines := m.tr.lines() if len(lines) == 0 { return cell{} } line := clampLine(m.vp.YOffset+row, lines) return cell{line: line, col: maxInt(0, minInt(x, lineWidth(lines, line)))} } // copySelection puts the marked text on the clipboard. The write happens off // the update loop because one of the two ways it is done shells out to the // desktop's clipboard helper, and that can be slow or hang. func (m *model) copySelection() tea.Cmd { lines := m.tr.lines() from, to, ok := m.sel.span(lines) if !ok { return nil } text := selectedText(lines, from, to) if text == "" { return nil } copyFn := m.copyFn return func() tea.Msg { return copiedMsg{text: text, err: copyFn(text)} } } func (m *model) clearSelection() { m.sel.clear() m.copied = "" m.clicks = 0 } // onChromeWheel rolls whatever is under the pointer. Over a list that is the // list — the wheel does what ↑↓ do — and over anything else it is still the // transcript, because a wheel that does nothing feels broken. func (m *model) onChromeWheel(kind sectionKind, msg tea.MouseMsg) tea.Cmd { up := msg.Button == tea.MouseButtonWheelUp switch kind { case secMenu: if n := len(m.menu.items); n > 0 { m.menu.idx = (m.menu.idx + n + step(up)) % n } return nil case secModel: if len(m.models) > 0 { m.modelIdx = clampIdx(m.modelIdx+step(up), len(m.models)) } return nil case secEffort: m.effortIdx = clampIdx(m.effortIdx+step(up), len(llm.EffortLevels)) return nil } var cmd tea.Cmd m.vp, cmd = m.vp.Update(msg) return cmd } // onChromeClick answers a click on the furniture. A click is a choice: it // does what pressing enter on that row would do, rather than only moving a // highlight there and waiting to be told again. func (m *model) onChromeClick(kind sectionKind, row, x int) (tea.Model, tea.Cmd) { switch kind { case secMenu: start, end := m.menuWindow() if i := start + row; i < end { m.menu.idx = i m.acceptCompletion() } return m, nil case secModel: if m.modelsLoading { return m, nil } // The heading and the line saying where the list came from sit above // the rows. start, end := m.modelWindow() if i := start + row - modelHeadRows; row >= modelHeadRows && i < end { m.modelIdx = i id := m.models[i].ID m.phase, m.models = phaseIdle, nil m.applyModel(id) return m, tea.Batch(m.sync(), textarea.Blink) } return m, nil case secEffort: if row != effortTrackRow && row != effortLabelRow { return m, nil } if i, ok := effortAt(x, m.width); ok { m.effortIdx = i m.phase = phaseIdle m.applyEffort(llm.EffortLevels[i]) return m, tea.Batch(m.sync(), textarea.Blink) } return m, nil case secApproval: if len(m.pending) == 0 { return m, nil } opts := m.choices(m.pending[0].req) if i := row - approvalChoiceRow(m.approvalView(), len(opts)); i >= 0 && i < len(opts) { return m, m.answerApproval(opts[i]) } return m, nil case secMode: return m, m.cycleMode() } return m, nil } // toggleMouse hands the mouse to the terminal or takes it back. With // reporting on, the terminal stops sending drags to its own selection and // sends them here instead — which is the whole feature, and also the cost of // it, because the terminal's selection is what works when the harness's own // is not what you want. func (m *model) toggleMouse() tea.Cmd { m.mouseOn = !m.mouseOn m.clearSelection() if m.mouseOn { m.tr.addNotice("mouse on — drag to select, double-click a word, triple-click a line, wheel scrolls") return tea.Batch(m.sync(), tea.EnableMouseCellMotion) } m.tr.addNotice("mouse off — your terminal's own selection and copy are back. /mouse to take it again") return tea.Batch(m.sync(), tea.DisableMouse) } // approvalChoiceRow is the row the first choice sits on inside the approval // box. The choices are the last thing in the box, so counting back from its // bottom border cannot drift from however the detail above them was laid out. func approvalChoiceRow(view string, choices int) int { return maxInt(0, lipgloss.Height(view)-1-choices) } // isWheel reports a wheel event. MouseMsg is its own type over MouseEvent, so // the event's own test is not promoted onto it. func isWheel(msg tea.MouseMsg) bool { return tea.MouseEvent(msg).IsWheel() } func step(up bool) int { if up { return -1 } return 1 } func clampIdx(i, n int) int { return maxInt(0, minInt(i, n-1)) }