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>
Showing 6 changed files with 438 additions and 51 deletions.
| @@ -102,9 +102,23 @@ level for `/effort` — and `esc` dismisses the menu without touching what you | ||
| 102 | 102 | typed. The menu offers exactly the commands the TUI answers, so nothing it |
| 103 | 103 | suggests can come back as "unknown command". |
| 104 | 104 | |
| 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. | |
| 108 | 122 | |
| 109 | 123 | `shift+tab` cycles between asking before every mutating call and accepting them |
| 110 | 124 | all, which is `-yolo` reached from the keyboard. The mode line says which is in |
| @@ -23,7 +23,7 @@ type command struct { | ||
| 23 | 23 | var builtins = []command{ |
| 24 | 24 | {name: "help", desc: "the commands and the keys"}, |
| 25 | 25 | {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"}, | |
| 27 | 27 | {name: "clear", desc: "empty the transcript"}, |
| 28 | 28 | {name: "allowed", desc: "what is permanently allowed this session"}, |
| 29 | 29 | {name: "exit", desc: "leave the harness"}, |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -53,6 +53,9 @@ const ( | ||
| 53 | 53 | phaseIdle phase = iota |
| 54 | 54 | phaseRunning |
| 55 | 55 | phaseApproving |
| 56 | + // phaseEffort is the effort slider: the prompt is gone until a level is | |
| 57 | + // picked or the slider is dismissed. | |
| 58 | + phaseEffort | |
| 56 | 59 | ) |
| 57 | 60 | |
| 58 | 61 | // inputMaxLines caps how far the prompt box grows before it scrolls. Past |
| @@ -91,6 +94,9 @@ type model struct { | ||
| 91 | 94 | // menu is the slash-command completion list under the prompt. |
| 92 | 95 | menu menu |
| 93 | 96 | |
| 97 | + // effortIdx is where the marker sits while the effort slider is up. | |
| 98 | + effortIdx int | |
| 99 | + | |
| 94 | 100 | // pending approvals. The agent blocks on one at a time, but a queue costs |
| 95 | 101 | // nothing and means a second request can never be dropped. |
| 96 | 102 | pending []approvalMsg |
| @@ -333,6 +339,9 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 333 | 339 | if m.phase == phaseApproving { |
| 334 | 340 | return m.onApprovalKey(msg) |
| 335 | 341 | } |
| 342 | + if m.phase == phaseEffort { | |
| 343 | + return m.onEffortKey(msg) | |
| 344 | + } | |
| 336 | 345 | |
| 337 | 346 | // An open menu owns the keys that drive it — including enter and esc, |
| 338 | 347 | // which mean something else the rest of the time. |
| @@ -491,42 +500,21 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) { | ||
| 491 | 500 | return m, tea.Batch(m.sync(), m.spin.Tick, m.runTurn(line)) |
| 492 | 501 | } |
| 493 | 502 | |
| 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. | |
| 496 | 506 | func (m *model) setEffort(level string) tea.Cmd { |
| 497 | 507 | switch { |
| 498 | 508 | case level == "": |
| 499 | - m.tr.addNotice("effort: " + effortListing(m.opts.Effort)) | |
| 509 | + return m.openEffort() | |
| 500 | 510 | case !llm.ValidEffort(level): |
| 501 | 511 | m.tr.addError("unknown effort " + level + " — one of " + strings.Join(llm.EffortLevels, ", ")) |
| 502 | 512 | 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) | |
| 508 | 514 | } |
| 509 | 515 | return m.sync() |
| 510 | 516 | } |
| 511 | 517 | |
| 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 | - | |
| 530 | 518 | // isSkill reports whether a slash command names a discovered skill. Matching |
| 531 | 519 | // here rather than sending every unrecognised slash to the model means a typo |
| 532 | 520 | // is caught by the harness instead of puzzled over by the agent. |
| @@ -653,9 +641,12 @@ func (m *model) View() string { | ||
| 653 | 641 | func (m *model) bottom() string { |
| 654 | 642 | var b strings.Builder |
| 655 | 643 | 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: | |
| 657 | 646 | b.WriteString(m.approvalView() + "\n") |
| 658 | - } else { | |
| 647 | + case m.phase == phaseEffort: | |
| 648 | + b.WriteString(m.effortView() + "\n") | |
| 649 | + default: | |
| 659 | 650 | b.WriteString(m.inputBox() + "\n") |
| 660 | 651 | if v := m.menuView(); v != "" { |
| 661 | 652 | b.WriteString(v + "\n") |
| @@ -43,6 +43,10 @@ func key(s string) tea.KeyMsg { | ||
| 43 | 43 | return tea.KeyMsg{Type: tea.KeyUp} |
| 44 | 44 | case "down": |
| 45 | 45 | 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} | |
| 46 | 50 | } |
| 47 | 51 | return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} |
| 48 | 52 | } |
| @@ -465,22 +469,3 @@ func TestEffortCommandRejectsAnUnknownLevel(t *testing.T) { | ||
| 465 | 469 | t.Error("a rejected effort still started a turn") |
| 466 | 470 | } |
| 467 | 471 | } |
| 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 | - } | |