Vectrum
Sign In

tui: /effort is a slider, not a level you have to remember

Bare /effort now swaps the prompt for a track with the five levels spread
across it, Faster to Smarter, the marker starting on the level in force —
which for an unset level is high, the API's own default, rather than an end
of the track nobody chose. Arrows move, 1-5 jump, enter applies, esc leaves
it alone; moving the marker on its own changes nothing.

The alignment of the marker over its label is the whole illusion, so the
track is built as plain runes and painted afterwards, and a test walks every
level at three widths checking the marker lands inside the label's cells.
Below about forty columns the labels cannot be laid out with room to breathe,
and a slider with the words sliced in half is worse than "◀ high ▶", so that
is what a narrow terminal gets.

/effort xhigh still applies directly: the picker is for choosing, not a toll
booth on a level you already know.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 32a55c5commit37f21fd

Showing 6 changed files with 438 additions and 51 deletions.

+17−3README.mdView File
@@ -102,9 +102,23 @@ level for `/effort` — and `esc` dismisses the menu without touching what you
102102 typed. The menu offers exactly the commands the TUI answers, so nothing it
103103 suggests can come back as "unknown command".
104104
105- `/effort` reports the levels and marks the one in force; `/effort xhigh` changes
106- it for the turns that follow, which is the `-effort` flag reached from the
107- keyboard. The setting shows above the prompt on the right.
105+ `/effort` opens a slider in place of the prompt, on the level in force — an
106+ unset level is the API's own default, `high`, so the marker starts there rather
107+ than at an end:
108+
109+ ```
110+ Faster Smarter
111+ ──────────────────────────────────────▲───────────────────────────────────
112+ low medium high xhigh max
113+ ←→ move · 1-5 jump · enter apply · esc cancel
114+ ```
115+
116+ Moving the marker changes nothing until `enter`; `esc` leaves the level alone.
117+ `/effort xhigh` still applies a level directly for anyone who can type it, and
118+ either way it is the `-effort` flag reached from the keyboard. The level in
119+ force shows above the prompt on the right. A terminal too narrow for five
120+ labels gets `◀ high ▶` and the same keys rather than a track with the words
121+ sliced in half.
108122
109123 `shift+tab` cycles between asking before every mutating call and accepting them
110124 all, which is `-yolo` reached from the keyboard. The mode line says which is in
+1−1internal/tui/commands.goView File
@@ -23,7 +23,7 @@ type command struct {
2323 var builtins = []command{
2424 {name: "help", desc: "the commands and the keys"},
2525 {name: "skills", desc: "the instruction sets found at startup"},
26- {name: "effort", args: "level", desc: "how hard the model thinks: low … max"},
26+ {name: "effort", args: "level", desc: "how hard the model thinks — opens a slider"},
2727 {name: "clear", desc: "empty the transcript"},
2828 {name: "allowed", desc: "what is permanently allowed this session"},
2929 {name: "exit", desc: "leave the harness"},
+152−0internal/tui/effort.goaddedView File
@@ -0,0 +1,152 @@
1+ package tui
2+
3+ import (
4+ "strings"
5+
6+ "github.com/charmbracelet/bubbles/textarea"
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/lipgloss"
9+
10+ "harness/internal/llm"
11+ )
12+
13+ // defaultEffort is where the marker sits when nothing has been chosen. The
14+ // API's own default is high, so an unset level is not "no level": it is that
15+ // one, and the slider says so rather than starting at an end.
16+ const defaultEffort = "high"
17+
18+ // openEffort puts the slider on screen, on the level currently in force.
19+ func (m *model) openEffort() tea.Cmd {
20+ m.effortIdx = effortIndex(m.opts.Effort)
21+ m.phase = phaseEffort
22+ m.closeMenu()
23+ return m.sync()
24+ }
25+
26+ func effortIndex(level string) int {
27+ if level == "" {
28+ level = defaultEffort
29+ }
30+ for i, l := range llm.EffortLevels {
31+ if l == level {
32+ return i
33+ }
34+ }
35+ return effortIndex("")
36+ }
37+
38+ // onEffortKey drives the slider. It answers every key while the slider is up:
39+ // there is no prompt behind it to type into, so nothing falls through.
40+ func (m *model) onEffortKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
41+ last := len(llm.EffortLevels) - 1
42+ switch key := msg.String(); key {
43+ case "left", "ctrl+b":
44+ // A slider has ends. Running past one holds rather than wrapping,
45+ // which would put max one keypress to the left of low.
46+ m.effortIdx = maxInt(0, m.effortIdx-1)
47+
48+ case "right", "ctrl+f":
49+ m.effortIdx = minInt(last, m.effortIdx+1)
50+
51+ case "home":
52+ m.effortIdx = 0
53+
54+ case "end":
55+ m.effortIdx = last
56+
57+ case "enter", "tab":
58+ m.phase = phaseIdle
59+ m.applyEffort(llm.EffortLevels[m.effortIdx])
60+ return m, tea.Batch(m.sync(), textarea.Blink)
61+
62+ case "esc", "ctrl+c":
63+ m.phase = phaseIdle
64+ return m, tea.Batch(m.sync(), textarea.Blink)
65+
66+ default:
67+ // 1 through 5 jump straight to a level. A digit past the last one is
68+ // not a level, so it does nothing rather than clamping to an end the
69+ // user did not aim at.
70+ if len(key) == 1 && key[0] >= '1' && key[0] <= '9' {
71+ if i := int(key[0] - '1'); i <= last {
72+ m.effortIdx = i
73+ }
74+ }
75+ }
76+ return m, m.sync()
77+ }
78+
79+ // applyEffort records the level, tells the agent, and says so in the
80+ // transcript. The agent is nil in tests; the level still takes, because the
81+ // chrome reads it from here.
82+ func (m *model) applyEffort(level string) {
83+ m.opts.Effort = level
84+ if m.opts.Agent != nil {
85+ m.opts.Agent.SetEffort(level)
86+ }
87+ m.tr.addNotice("effort: " + level + " — from the next turn on")
88+ }
89+
90+ // effortView is the slider: the poles, the track with the marker riding it,
91+ // the labels under it, and the keys that drive it.
92+ //
93+ // Faster Smarter
94+ // ─────────────────────────────▲───────────────────────────────────
95+ // low medium high xhigh max
96+ func (m *model) effortView() string {
97+ width := maxInt(1, m.width)
98+
99+ starts := effortSlots(width - 1)
100+ if starts == nil {
101+ // Too narrow for a track. The level and the keys still fit, and they
102+ // are the part that does the work.
103+ level := llm.EffortLevels[m.effortIdx]
104+ compact := styFaint.Render("◀ ") + styKey.Render(level) + styFaint.Render(" ▶")
105+ return truncateStyled(" "+compact, width) + "\n" +
106+ truncateStyled(" "+styFaint.Render("enter apply · esc cancel"), width)
107+ }
108+
109+ // The track is plain runes until the marker is placed on it, so the
110+ // marker's column is arithmetic rather than a sum of escape sequences.
111+ track := []rune(strings.Repeat("─", width-1))
112+ var labels strings.Builder
113+ col := 0
114+ for i, level := range llm.EffortLevels {
115+ if gap := starts[i] - col; gap > 0 {
116+ labels.WriteString(strings.Repeat(" ", gap))
117+ col += gap
118+ }
119+ style := styDim
120+ if i == m.effortIdx {
121+ style = styKey
122+ track[starts[i]+lipgloss.Width(level)/2] = '▲'
123+ }
124+ labels.WriteString(style.Render(level))
125+ col += lipgloss.Width(level)
126+ }
127+
128+ return spread(styDim.Render("Faster"), styDim.Render("Smarter"), width) + "\n" +
129+ " " + styFaint.Render(string(track)) + "\n" +
130+ " " + labels.String() + "\n" +
131+ truncateStyled(" "+styFaint.Render("←→ move · 1-5 jump · enter apply · esc cancel"), width)
132+ }
133+
134+ // effortSlots is the column each label starts at, spread evenly across width.
135+ // It returns nil when the labels cannot be laid out with room to breathe,
136+ // which is the caller's cue to render something simpler.
137+ func effortSlots(width int) []int {
138+ n := len(llm.EffortLevels)
139+ longest := 0
140+ for _, l := range llm.EffortLevels {
141+ longest = maxInt(longest, lipgloss.Width(l))
142+ }
143+ slot := width / n
144+ if slot < longest+2 {
145+ return nil
146+ }
147+ starts := make([]int, n)
148+ for i, l := range llm.EffortLevels {
149+ starts[i] = i*slot + (slot-lipgloss.Width(l))/2
150+ }
151+ return starts
152+ }
+245−0internal/tui/effort_test.goaddedView File
@@ -0,0 +1,245 @@
1+ package tui
2+
3+ import (
4+ "strings"
5+ "testing"
6+
7+ tea "github.com/charmbracelet/bubbletea"
8+
9+ "harness/internal/llm"
10+ )
11+
12+ // levelAt is where a level sits on the track, so tests can talk about
13+ // positions without hard-coding the order.
14+ func levelAt(name string) int {
15+ for i, l := range llm.EffortLevels {
16+ if l == name {
17+ return i
18+ }
19+ }
20+ return -1
21+ }
22+
23+ // runeIndex is strings.Index counted in cells rather than in bytes.
24+ func runeIndex(haystack, needle string) int {
25+ i := strings.Index(haystack, needle)
26+ if i < 0 {
27+ return -1
28+ }
29+ return len([]rune(haystack[:i]))
30+ }
31+
32+ // Bare /effort opens the slider on the level in force. Unset is not "nothing
33+ // selected": the API's default is high, and that is where the marker belongs.
34+ func TestEffortPickerOpensOnTheLevelInForce(t *testing.T) {
35+ for _, tc := range []struct{ effort, want string }{
36+ {"", "high"},
37+ {"low", "low"},
38+ {"max", "max"},
39+ } {
40+ m := newTestModel(t)
41+ m.opts.Effort = tc.effort
42+ m.submit("/effort")
43+
44+ if m.phase != phaseEffort {
45+ t.Fatalf("effort %q: phase = %v, want the picker open", tc.effort, m.phase)
46+ }
47+ if got := llm.EffortLevels[m.effortIdx]; got != tc.want {
48+ t.Errorf("effort %q: marker on %q, want %q", tc.effort, got, tc.want)
49+ }
50+ }
51+ }
52+
53+ // It is a slider, so the ends are ends: moving past one stays put rather than
54+ // wrapping around to the opposite extreme.
55+ func TestEffortPickerMovesAndClampsAtTheEnds(t *testing.T) {
56+ m := newTestModel(t)
57+ m.opts.Effort = "high"
58+ m.submit("/effort")
59+
60+ m.Update(key("right"))
61+ if got := m.effortIdx; got != levelAt("xhigh") {
62+ t.Errorf("right put the marker on %q", llm.EffortLevels[got])
63+ }
64+ m.Update(key("left"))
65+ m.Update(key("left"))
66+ if got := m.effortIdx; got != levelAt("medium") {
67+ t.Errorf("two lefts put the marker on %q", llm.EffortLevels[got])
68+ }
69+
70+ for i := 0; i < 5; i++ {
71+ m.Update(key("left"))
72+ }
73+ if got := m.effortIdx; got != 0 {
74+ t.Errorf("marker ran off the cheap end to %d", got)
75+ }
76+ for i := 0; i < 9; i++ {
77+ m.Update(key("right"))
78+ }
79+ if got, want := m.effortIdx, len(llm.EffortLevels)-1; got != want {
80+ t.Errorf("marker = %d, want it held at %d", got, want)
81+ }
82+ }
83+
84+ func TestEffortPickerNumbersJumpToALevel(t *testing.T) {
85+ m := newTestModel(t)
86+ m.submit("/effort")
87+
88+ m.Update(key("1"))
89+ if got := llm.EffortLevels[m.effortIdx]; got != "low" {
90+ t.Errorf("1 selected %q, want low", got)
91+ }
92+ m.Update(key("5"))
93+ if got := llm.EffortLevels[m.effortIdx]; got != "max" {
94+ t.Errorf("5 selected %q, want max", got)
95+ }
96+ // There is no sixth level; the key is not a level, so nothing moves.
97+ m.Update(key("6"))
98+ if got := llm.EffortLevels[m.effortIdx]; got != "max" {
99+ t.Errorf("6 moved the marker to %q", got)
100+ }
101+ }
102+
103+ // Moving the marker changes nothing until enter; enter closes the picker and
104+ // the level takes.
105+ func TestEffortPickerAppliesOnEnter(t *testing.T) {
106+ m := newTestModel(t)
107+ m.opts.Effort = "high"
108+ m.submit("/effort")
109+
110+ m.Update(key("right"))
111+ if m.opts.Effort != "high" {
112+ t.Errorf("effort = %q — moving the marker must not change anything", m.opts.Effort)
113+ }
114+
115+ m.Update(key("enter"))
116+ if m.opts.Effort != "xhigh" {
117+ t.Errorf("effort = %q, want xhigh", m.opts.Effort)
118+ }
119+ if m.phase != phaseIdle {
120+ t.Errorf("phase = %v after applying, want the prompt back", m.phase)
121+ }
122+ if !strings.Contains(m.tr.render(), "xhigh") {
123+ t.Error("the change was not reported in the transcript")
124+ }
125+ if !strings.Contains(m.activity(), "xhigh") {
126+ t.Error("the effort pill did not follow")
127+ }
128+ }
129+
130+ func TestEffortPickerCancelsOnEscape(t *testing.T) {
131+ m := newTestModel(t)
132+ m.opts.Effort = "low"
133+ m.submit("/effort")
134+
135+ m.Update(key("right"))
136+ m.Update(key("esc"))
137+
138+ if m.opts.Effort != "low" {
139+ t.Errorf("effort = %q, want the level left alone", m.opts.Effort)
140+ }
141+ if m.phase != phaseIdle {
142+ t.Errorf("phase = %v after cancelling, want the prompt back", m.phase)
143+ }
144+ }
145+
146+ // Typing the level is still the fast path: it applies without a picker to
147+ // dismiss.
148+ func TestTypedLevelSkipsThePicker(t *testing.T) {
149+ m := newTestModel(t)
150+ m.submit("/effort max")
151+
152+ if m.phase != phaseIdle {
153+ t.Errorf("phase = %v, want no picker for a level that was typed out", m.phase)
154+ }
155+ if m.opts.Effort != "max" {
156+ t.Errorf("effort = %q, want max", m.opts.Effort)
157+ }
158+ }
159+
160+ // The marker has to sit over the label it selects — that alignment is the
161+ // whole illusion of a slider.
162+ func TestSliderMarkerSitsOverTheSelectedLabel(t *testing.T) {
163+ for _, width := range []int{60, 80, 120} {
164+ for i, level := range llm.EffortLevels {
165+ m := newTestModel(t)
166+ m.Update(tea.WindowSizeMsg{Width: width, Height: 30})
167+ m.submit("/effort")
168+ m.effortIdx = i
169+
170+ lines := strings.Split(m.effortView(), "\n")
171+ if len(lines) < 4 {
172+ t.Fatalf("width %d: the slider is %d lines: %q", width, len(lines), lines)
173+ }
174+ // Columns are cells, not bytes: the track is drawn in box
175+ // characters that are three bytes each.
176+ track, labels := lines[1], lines[2]
177+ marker := runeIndex(track, "▲")
178+ if marker < 0 {
179+ t.Fatalf("width %d: no marker on the track %q", width, track)
180+ }
181+ start := runeIndex(labels, level)
182+ if start < 0 {
183+ t.Fatalf("width %d: %q missing from the labels %q", width, level, labels)
184+ }
185+ if marker < start || marker >= start+len(level) {
186+ t.Errorf("width %d, %s: marker at column %d, label spans %d-%d\n%s\n%s",
187+ width, level, marker, start, start+len(level)-1, track, labels)
188+ }
189+ }
190+ }
191+ }
192+
193+ func TestSliderShowsEveryLevelAndItsPoles(t *testing.T) {
194+ m := newTestModel(t)
195+ m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
196+ m.submit("/effort")
197+
198+ view := m.effortView()
199+ for _, want := range append([]string{"Faster", "Smarter", "enter", "esc"}, llm.EffortLevels...) {
200+ if !strings.Contains(view, want) {
201+ t.Errorf("%q missing from the slider:\n%s", want, view)
202+ }
203+ }
204+ }
205+
206+ // A terminal too narrow for five labels gets the level and the keys rather
207+ // than a track with the words sliced in half.
208+ func TestSliderFallsBackOnANarrowTerminal(t *testing.T) {
209+ m := newTestModel(t)
210+ m.Update(tea.WindowSizeMsg{Width: 30, Height: 20})
211+ m.opts.Effort = "xhigh"
212+ m.submit("/effort")
213+
214+ view := m.effortView()
215+ if !strings.Contains(view, "xhigh") {
216+ t.Errorf("the selected level is missing:\n%s", view)
217+ }
218+ if strings.Contains(view, "medium") {
219+ t.Errorf("a label that cannot fit was rendered anyway:\n%s", view)
220+ }
221+ for _, line := range strings.Split(view, "\n") {
222+ if len(line) > 30 {
223+ t.Errorf("line is %d cells wide on a 30-cell terminal: %q", len(line), line)
224+ }
225+ }
226+ if got := strings.Count(m.View(), "\n") + 1; got > 20 {
227+ t.Errorf("the screen is %d lines on a 20-line terminal", got)
228+ }
229+ }
230+
231+ // While the picker owns the screen there is no prompt to type into, so keys
232+ // that are not its own must not leak into the textarea.
233+ func TestPickerSwallowsTyping(t *testing.T) {
234+ m := newTestModel(t)
235+ m.submit("/effort")
236+
237+ typeIn(m, "hello")
238+
239+ if got := m.input.Value(); got != "" {
240+ t.Errorf("input = %q, want the picker to have eaten those keys", got)
241+ }
242+ if m.phase != phaseEffort {
243+ t.Errorf("phase = %v, want the picker still open", m.phase)
244+ }
245+ }
+19−28internal/tui/model.goView File
@@ -53,6 +53,9 @@ const (
5353 phaseIdle phase = iota
5454 phaseRunning
5555 phaseApproving
56+ // phaseEffort is the effort slider: the prompt is gone until a level is
57+ // picked or the slider is dismissed.
58+ phaseEffort
5659 )
5760
5861 // inputMaxLines caps how far the prompt box grows before it scrolls. Past
@@ -91,6 +94,9 @@ type model struct {
9194 // menu is the slash-command completion list under the prompt.
9295 menu menu
9396
97+ // effortIdx is where the marker sits while the effort slider is up.
98+ effortIdx int
99+
94100 // pending approvals. The agent blocks on one at a time, but a queue costs
95101 // nothing and means a second request can never be dropped.
96102 pending []approvalMsg
@@ -333,6 +339,9 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
333339 if m.phase == phaseApproving {
334340 return m.onApprovalKey(msg)
335341 }
342+ if m.phase == phaseEffort {
343+ return m.onEffortKey(msg)
344+ }
336345
337346 // An open menu owns the keys that drive it — including enter and esc,
338347 // which mean something else the rest of the time.
@@ -491,42 +500,21 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
491500 return m, tea.Batch(m.sync(), m.spin.Tick, m.runTurn(line))
492501 }
493502
494- // setEffort answers /effort. Bare, it reports the levels and marks the one in
495- // force; with a level, it changes what the turns that follow ask for.
503+ // setEffort answers /effort. Bare, it opens the slider; with a level, it
504+ // applies it directly, which is the fast path for anyone who knows what they
505+ // want and can type it.
496506 func (m *model) setEffort(level string) tea.Cmd {
497507 switch {
498508 case level == "":
499- m.tr.addNotice("effort: " + effortListing(m.opts.Effort))
509+ return m.openEffort()
500510 case !llm.ValidEffort(level):
501511 m.tr.addError("unknown effort " + level + " — one of " + strings.Join(llm.EffortLevels, ", "))
502512 default:
503- m.opts.Effort = level
504- if m.opts.Agent != nil {
505- m.opts.Agent.SetEffort(level)
506- }
507- m.tr.addNotice("effort: " + level + " — from the next turn on")
513+ m.applyEffort(level)
508514 }
509515 return m.sync()
510516 }
511517
512- // effortListing names the levels and marks the current one. An empty current
513- // level is the API's own default rather than a level this harness picked, and
514- // it says so instead of silently marking nothing.
515- func effortListing(current string) string {
516- parts := make([]string, 0, len(llm.EffortLevels))
517- for _, l := range llm.EffortLevels {
518- if l == current {
519- l = "● " + l
520- }
521- parts = append(parts, l)
522- }
523- out := strings.Join(parts, " ")
524- if current == "" {
525- out += " (the API default until you pick one)"
526- }
527- return out
528- }
529-
530518 // isSkill reports whether a slash command names a discovered skill. Matching
531519 // here rather than sending every unrecognised slash to the model means a typo
532520 // is caught by the harness instead of puzzled over by the agent.
@@ -653,9 +641,12 @@ func (m *model) View() string {
653641 func (m *model) bottom() string {
654642 var b strings.Builder
655643 b.WriteString(m.activity() + "\n")
656- if m.phase == phaseApproving && len(m.pending) > 0 {
644+ switch {
645+ case m.phase == phaseApproving && len(m.pending) > 0:
657646 b.WriteString(m.approvalView() + "\n")
658- } else {
647+ case m.phase == phaseEffort:
648+ b.WriteString(m.effortView() + "\n")
649+ default:
659650 b.WriteString(m.inputBox() + "\n")
660651 if v := m.menuView(); v != "" {
661652 b.WriteString(v + "\n")
+4−19internal/tui/model_test.goView File
@@ -43,6 +43,10 @@ func key(s string) tea.KeyMsg {
4343 return tea.KeyMsg{Type: tea.KeyUp}
4444 case "down":
4545 return tea.KeyMsg{Type: tea.KeyDown}
46+ case "left":
47+ return tea.KeyMsg{Type: tea.KeyLeft}
48+ case "right":
49+ return tea.KeyMsg{Type: tea.KeyRight}
4650 }
4751 return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
4852 }
@@ -465,22 +469,3 @@ func TestEffortCommandRejectsAnUnknownLevel(t *testing.T) {
465469 t.Error("a rejected effort still started a turn")
466470 }
467471 }
468-
469- func TestBareEffortReportsTheLevelsAndTheCurrentOne(t *testing.T) {
470- m := newTestModel(t)
471- m.opts.Effort = "medium"
472- m.submit("/effort")
473-
474- out := m.tr.render()
475- for _, level := range llm.EffortLevels {
476- if !strings.Contains(out, level) {
477- t.Errorf("level %q missing from %q", level, out)
478- }
479- }
480- if !strings.Contains(out, "● medium") {
481- t.Errorf("the level in force is not marked: %q", out)
482- }
483- if m.phase != phaseIdle {
484- t.Error("asking about effort started a turn")
485- }
486- }