package tui
import (
"strings"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/johannSo/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
}