package tui import ( "strings" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "harness/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() } // applyEffort records the level, tells the agent, and says so in the // transcript. The agent is nil in tests; the level still takes, because the // chrome reads it from here. func (m *model) applyEffort(level string) { m.opts.Effort = level if m.opts.Agent != nil { m.opts.Agent.SetEffort(level) } m.tr.addNotice("effort: " + level + " — from the next turn on") } // 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.width) 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) } // 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 }