package tui
import (
"time"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/johannSo/codey/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
secAsk
secEffort
secModel
secSettings
secSessions
secWorktrees
)
// 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 == phaseAsking && len(m.asking) > 0:
secs = append(secs, section{secAsk, m.askView()})
case m.phase == phaseEffort:
secs = append(secs, section{secEffort, m.effortView()})
case m.phase == phaseModel:
secs = append(secs, section{secModel, m.modelView()})
case m.phase == phaseSettings:
secs = append(secs, section{secSettings, m.settingsView()})
case m.phase == phaseSessions:
secs = append(secs, section{secSessions, m.sessionView()})
case m.phase == phaseWorktrees:
secs = append(secs, section{secWorktrees, m.worktreeView()})
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 agents pane is a column of its own. A drag that has wandered into
// it is still the transcript's drag, so an in-flight selection keeps
// every event it started.
var focus tea.Cmd
if m.bar.visible() && !m.sel.drag {
if m.fullScreenSidebar() || msg.X >= m.width-m.sidebarWidth() {
return m, m.onSidebarMouse(msg)
}
// A press anywhere else says you are working in the conversation
// again, and the keyboard follows.
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
focus = m.focusPrompt()
}
}
mdl, cmd := m.dispatchMouse(msg)
return mdl, tea.Batch(focus, cmd)
}
// dispatchMouse resolves an event against the chrome the frame drew.
func (m *model) dispatchMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
// 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 {
// The transcript on screen, which is a subagent's thread while one is
// open: selecting in it should select what is being read, not the
// conversation underneath.
lines := m.activeTranscript().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.activeTranscript().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 m.tag(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 secSessions:
m.sessionIdx = clampIdx(m.sessionIdx+step(up), len(m.sessions))
return nil
case secWorktrees:
m.worktreeIdx = clampIdx(m.worktreeIdx+step(up), len(m.worktreeShown))
return nil
case secEffort:
m.effortIdx = clampIdx(m.effortIdx+step(up), len(llm.EffortLevels))
return nil
case secAsk:
// The wheel moves the cursor and nothing else, for the reason the
// settings panel gives: a roll that answered the question on its own
// would be a decision nobody made.
if len(m.asking) > 0 && !m.asking[0].typing {
m.asking[0].move(step(up))
}
return nil
case secSettings:
// The wheel moves between rows rather than changing a value: a roll
// that silently turned approvals off would be the worst possible
// thing for it to do.
m.setIdx = settingRow(clampIdx(int(m.setIdx)+step(up), int(numSettingRows)))
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 = m.backFromPicker(), nil
m.applyModel(id)
return m, tea.Batch(m.sync(), textarea.Blink)
}
return m, nil
case secSessions:
// The heading and the line naming the directory sit above the rows.
start, end := m.sessionWindow()
if i := start + row - sessionHeadRows; row >= sessionHeadRows && i < end {
m.sessionIdx = i
id := m.sessions[i].ID
m.phase, m.sessions = phaseIdle, nil
m.resumeSession(id)
return m, tea.Batch(m.sync(), textarea.Blink)
}
return m, nil
case secWorktrees:
// The heading and the filter line sit above the rows.
start, end := m.worktreeWindow()
if i := start + row - worktreeHeadRows; row >= worktreeHeadRows && i < end {
m.worktreeIdx = i
w := m.worktreeShown[i]
m.closeWorktreePicker()
m.openWorktreeChat(w)
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 secAsk:
if len(m.asking) == 0 {
return m, nil
}
// The hit map is what the last frame actually drew, so a click on the
// description under a label lands on that option rather than between
// two of them. Row 0 of the section is the box's top border, which is
// what the map does not count.
s := m.asking[0]
if s.typing {
return m, nil
}
if i, ok := s.hit[row-1]; ok {
s.idx = i
return m, m.activateAsk(s)
}
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 secSettings:
// A click on a row is a choice, like everywhere else in the chrome:
// it moves the cursor there and does what enter would have done.
i := row - settingsHeadRows
if i < 0 || i >= int(numSettingRows) {
return m, nil
}
m.setIdx = settingRow(i)
if m.setIdx == setModelRow {
m.setFrom = true
return m, m.openModelPicker()
}
m.cycleSetting(m.setIdx, 1)
return m, m.sync()
case secStatus:
// The agents pill is the only clickable thing in this line.
if m.opts.Agents != nil && m.pill.hit(x, 0) {
return m, m.togglePane()
}
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 Codey'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)) }