Vectrum
Sign In
475 lines · 14.1 KB · Go
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)) }