Vectrum
Sign In
201 lines · 6.1 KB · Go
package tui

import (
	"strings"

	"github.com/charmbracelet/bubbles/textarea"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"

	"codey/internal/llm"
)

// defaultEffort is where the marker sits when nothing has been chosen. The
// API's own default is high, so an unset level is not "no level": it is that
// one, and the slider says so rather than starting at an end.
const defaultEffort = "high"

// openEffort puts the slider on screen, on the level currently in force.
func (m *model) openEffort() tea.Cmd {
	m.effortIdx = effortIndex(m.opts.Effort)
	m.phase = phaseEffort
	m.closeMenu()
	return m.sync()
}

func effortIndex(level string) int {
	if level == "" {
		level = defaultEffort
	}
	for i, l := range llm.EffortLevels {
		if l == level {
			return i
		}
	}
	return effortIndex("")
}

// onEffortKey drives the slider. It answers every key while the slider is up:
// there is no prompt behind it to type into, so nothing falls through.
func (m *model) onEffortKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
	last := len(llm.EffortLevels) - 1
	switch key := msg.String(); key {
	case "left", "ctrl+b":
		// A slider has ends. Running past one holds rather than wrapping,
		// which would put max one keypress to the left of low.
		m.effortIdx = maxInt(0, m.effortIdx-1)

	case "right", "ctrl+f":
		m.effortIdx = minInt(last, m.effortIdx+1)

	case "home":
		m.effortIdx = 0

	case "end":
		m.effortIdx = last

	case "enter", "tab":
		m.phase = phaseIdle
		m.applyEffort(llm.EffortLevels[m.effortIdx])
		return m, tea.Batch(m.sync(), textarea.Blink)

	case "esc", "ctrl+c":
		m.phase = phaseIdle
		return m, tea.Batch(m.sync(), textarea.Blink)

	default:
		// 1 through 5 jump straight to a level. A digit past the last one is
		// not a level, so it does nothing rather than clamping to an end the
		// user did not aim at.
		if len(key) == 1 && key[0] >= '1' && key[0] <= '9' {
			if i := int(key[0] - '1'); i <= last {
				m.effortIdx = i
			}
		}
	}
	return m, m.sync()
}

// useEffort records the level and tells the agent. The agent is nil in tests;
// the level still takes, because the chrome reads it from here.
//
// It is separate from applyEffort for the same reason useModel is separate
// from applyModel: the wizard sets several things and reports them together.
func (m *model) useEffort(level string) {
	m.opts.Effort = level
	if m.opts.Agent != nil {
		m.opts.Agent.SetEffort(level)
	}
}

// applyEffort records the level and says so in the transcript.
func (m *model) applyEffort(level string) {
	m.useEffort(level)
	m.tr.addNotice("effort: " + level + " — from the next turn on")
}

// The slider's four rows. Two of them are the slider — the track and the
// labels under it — and two are words about it, which is the difference
// between a click that moves the marker and a click on a caption.
const (
	effortPoleRow = iota
	effortTrackRow
	effortLabelRow
	effortKeysRow
)

// effortView is the slider: the poles, the track with the marker riding it,
// the labels under it, and the keys that drive it.
//
//	Faster                                                    Smarter
//	─────────────────────────────▲───────────────────────────────────
//	  low          medium         high          xhigh          max
func (m *model) effortView() string {
	width := maxInt(1, m.mainWidth())

	starts := effortSlots(width - 1)
	if starts == nil {
		// Too narrow for a track. The level and the keys still fit, and they
		// are the part that does the work.
		level := llm.EffortLevels[m.effortIdx]
		compact := styFaint.Render("◀ ") + styKey.Render(level) + styFaint.Render(" ▶")
		return truncateStyled(" "+compact, width) + "\n" +
			truncateStyled(" "+styFaint.Render("enter apply · esc cancel"), width)
	}

	// The track is plain runes until the marker is placed on it, so the
	// marker's column is arithmetic rather than a sum of escape sequences.
	track := []rune(strings.Repeat("─", width-1))
	var labels strings.Builder
	col := 0
	for i, level := range llm.EffortLevels {
		if gap := starts[i] - col; gap > 0 {
			labels.WriteString(strings.Repeat(" ", gap))
			col += gap
		}
		style := styDim
		if i == m.effortIdx {
			style = styKey
			track[starts[i]+lipgloss.Width(level)/2] = '▲'
		}
		labels.WriteString(style.Render(level))
		col += lipgloss.Width(level)
	}

	return spread(styDim.Render("Faster"), styDim.Render("Smarter"), width) + "\n" +
		" " + styFaint.Render(string(track)) + "\n" +
		" " + labels.String() + "\n" +
		truncateStyled(" "+styFaint.Render("←→ move · 1-5 jump · enter apply · esc cancel"), width)
}

// effortAt is which level was clicked at column x. The nearest label wins
// rather than only a hit on the word itself: the track between two labels
// belongs to one of them, and a click on a slider that does nothing reads as
// a broken slider.
//
// It goes through effortSlots, the same function the view lays the labels out
// with, so the level a click picks is the one under the pointer whatever the
// width does to the spacing.
func effortAt(x, width int) (int, bool) {
	width = maxInt(1, width)
	starts := effortSlots(width - 1)
	if starts == nil {
		return 0, false
	}
	best, bestDist := 0, -1
	for i, level := range llm.EffortLevels {
		// The labels are drawn one column in, after the leading margin.
		centre := starts[i] + 1 + lipgloss.Width(level)/2
		if d := absInt(x - centre); bestDist < 0 || d < bestDist {
			best, bestDist = i, d
		}
	}
	return best, true
}

func absInt(n int) int {
	if n < 0 {
		return -n
	}
	return n
}

// effortSlots is the column each label starts at, spread evenly across width.
// It returns nil when the labels cannot be laid out with room to breathe,
// which is the caller's cue to render something simpler.
func effortSlots(width int) []int {
	n := len(llm.EffortLevels)
	longest := 0
	for _, l := range llm.EffortLevels {
		longest = maxInt(longest, lipgloss.Width(l))
	}
	slot := width / n
	if slot < longest+2 {
		return nil
	}
	starts := make([]int, n)
	for i, l := range llm.EffortLevels {
		starts[i] = i*slot + (slot-lipgloss.Width(l))/2
	}
	return starts
}