Vectrum
Sign In

tui: a completion menu for slash commands, and a working /effort

Typing a slash at the start of an empty prompt now opens a menu of every
command the TUI answers — the built-ins and one row per discovered skill —
filtered as you type, prefix matches ahead of substring ones. Up and down
move, tab or enter completes into the prompt box, esc dismisses without
touching what was typed. Completing is not sending: a skill and /effort both
take arguments, and a menu that fired the command on enter would leave
nowhere to type them.

The menu, /help and the unknown-command check all read one catalogue, so the
menu cannot advertise a command that comes back as "unknown command" — there
is a test that walks the catalogue through submit to keep it that way.

/effort was advertised above the prompt but never implemented. It is now:
bare it reports the levels and marks the one in force, with a level it
changes what the following turns ask for. The agent grows SetEffort for it,
guarded by a mutex because the loop reads the level on its own goroutine.

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

Showing 10 changed files with 708 additions and 9 deletions.

+26−1README.mdView File
@@ -72,6 +72,7 @@ to six lines.
7272 | Key | Does |
7373 | --- | --- |
7474 | `enter` | send |
75+ | `/` | open the command menu — see below |
7576 | `ctrl+j` | newline without sending |
7677 | `shift+tab` | cycle the approval mode |
7778 | `ctrl+r` | expand elided tool output |
@@ -79,7 +80,31 @@ to six lines.
7980 | `esc` | interrupt the turn |
8081 | `ctrl+c` | interrupt the turn; again when idle to exit |
8182
82- Commands: `/allowed`, `/clear`, `/help`, `/exit`, `/skills`.
83+ Commands: `/allowed`, `/clear`, `/effort`, `/exit`, `/help`, `/skills`, and one
84+ per discovered skill.
85+
86+ A slash at the start of an empty prompt opens the command menu, which filters
87+ as you type — a name that starts with what you typed before one that merely
88+ contains it:
89+
90+ ```
91+ ╭──────────────────────────────────────────────────────────────────────────────╮
92+ │ › /s │
93+ ╰──────────────────────────────────────────────────────────────────────────────╯
94+ ❯ /skills the instruction sets found at startup
95+ /systematic-debugging find the cause before changing code (project)
96+ /brainstorming explore an idea before building it (personal)
97+ ```
98+
99+ `↑`/`↓` move, `tab` or `enter` completes the highlighted name into the prompt —
100+ completing is not sending, so there is room to type arguments for a skill or a
101+ level for `/effort` — and `esc` dismisses the menu without touching what you
102+ typed. The menu offers exactly the commands the TUI answers, so nothing it
103+ suggests can come back as "unknown command".
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.
83108
84109 `shift+tab` cycles between asking before every mutating call and accepting them
85110 all, which is `-yolo` reached from the keyboard. The mode line says which is in
+22−2internal/agent/loop.goView File
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "fmt"
88 "strings"
9+ "sync"
910
1011 "harness/internal/llm"
1112 "harness/internal/metrics"
@@ -126,6 +127,25 @@ type Agent struct {
126127 // lastInputTokens is the size of the previous request as the API measured
127128 // it, cached and uncached parts together.
128129 lastInputTokens int
130+
131+ // mu guards the settings a front end can change while the loop is
132+ // running. Only effort is one today; the loop reads it once per request.
133+ mu sync.Mutex
134+ }
135+
136+ // SetEffort changes the effort level the turns that follow will ask for. An
137+ // empty level means the API's own default.
138+ func (a *Agent) SetEffort(level string) {
139+ a.mu.Lock()
140+ defer a.mu.Unlock()
141+ a.opts.Effort = level
142+ }
143+
144+ // Effort reports the level currently in force.
145+ func (a *Agent) Effort() string {
146+ a.mu.Lock()
147+ defer a.mu.Unlock()
148+ return a.opts.Effort
129149 }
130150
131151 // New wires an agent. state and log may be freshly created by the caller so the
@@ -320,8 +340,8 @@ func (a *Agent) request() llm.Request {
320340 Tools: defs,
321341 Thinking: llm.Adaptive(),
322342 }
323- if a.opts.Effort != "" {
324- req.OutputConfig = &llm.OutputConfig{Effort: a.opts.Effort}
343+ if effort := a.Effort(); effort != "" {
344+ req.OutputConfig = &llm.OutputConfig{Effort: effort}
325345 }
326346 return req
327347 }
+26−0internal/agent/loop_test.goView File
@@ -527,3 +527,29 @@ func TestSystemPromptIsStableAcrossRequests(t *testing.T) {
527527 t.Error("the system prompt changed between two requests in one session")
528528 }
529529 }
530+
531+ // Effort is a knob the TUI turns mid-session, so a request carries whatever
532+ // was last set rather than whatever the process started with.
533+ func TestSetEffortAppliesToTheNextRequest(t *testing.T) {
534+ f := newFakeAPI(t, msg("end_turn", textBlock("one")), msg("end_turn", textBlock("two")))
535+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
536+
537+ if _, err := a.Run(context.Background(), "first"); err != nil {
538+ t.Fatalf("Run: %v", err)
539+ }
540+ if oc := f.requests[0].OutputConfig; oc != nil {
541+ t.Errorf("output_config = %+v with no effort set, want it omitted so the API picks", oc)
542+ }
543+
544+ a.SetEffort("low")
545+ if got := a.Effort(); got != "low" {
546+ t.Errorf("Effort() = %q, want low", got)
547+ }
548+ if _, err := a.Run(context.Background(), "second"); err != nil {
549+ t.Fatalf("Run: %v", err)
550+ }
551+ oc := f.requests[1].OutputConfig
552+ if oc == nil || oc.Effort != "low" {
553+ t.Errorf("output_config = %+v, want effort low", oc)
554+ }
555+ }
+15−0internal/llm/types.goView File
@@ -172,6 +172,21 @@ type OutputConfig struct {
172172 Effort string `json:"effort,omitempty"`
173173 }
174174
175+ // EffortLevels are the values output_config.effort accepts, cheapest first.
176+ // Anything else is a 400 from the API, so callers check before sending.
177+ var EffortLevels = []string{"low", "medium", "high", "xhigh", "max"}
178+
179+ // ValidEffort reports whether level is one the API accepts. The empty string
180+ // is not: it means "say nothing", which callers handle by omitting the field.
181+ func ValidEffort(level string) bool {
182+ for _, l := range EffortLevels {
183+ if l == level {
184+ return true
185+ }
186+ }
187+ return false
188+ }
189+
175190 // Request is the body of POST /v1/messages.
176191 type Request struct {
177192 Model string `json:"model"`
+15−0internal/llm/types_test.goView File
@@ -122,3 +122,18 @@ func TestResponseAccessors(t *testing.T) {
122122 t.Errorf("Message() = %+v, want all 4 blocks under the assistant role", msg)
123123 }
124124 }
125+
126+ // The effort levels are a closed set, and both the -effort flag and the TUI's
127+ // /effort command answer out of it rather than keeping their own copy.
128+ func TestValidEffort(t *testing.T) {
129+ for _, level := range EffortLevels {
130+ if !ValidEffort(level) {
131+ t.Errorf("ValidEffort(%q) = false for a level we advertise", level)
132+ }
133+ }
134+ for _, level := range []string{"", "HIGH", "higher", "none"} {
135+ if ValidEffort(level) {
136+ t.Errorf("ValidEffort(%q) = true, want false", level)
137+ }
138+ }
139+ }
+84−0internal/tui/commands.goaddedView File
@@ -0,0 +1,84 @@
1+ package tui
2+
3+ import (
4+ "fmt"
5+ "strings"
6+ )
7+
8+ // command is one thing the prompt box accepts with a leading slash: either a
9+ // control the TUI answers itself, or a skill the agent is asked to invoke.
10+ type command struct {
11+ name string // without the slash
12+ args string // what may follow the name; "" when it takes none
13+ desc string
14+ tag string // "personal" or "project" for a skill; "" for a built-in
15+ }
16+
17+ // builtins are the commands submit answers without going near the model. The
18+ // order is the order the menu offers them in — most reached-for first, not
19+ // alphabetical, because the first row is the one a person lands on.
20+ //
21+ // This list is also what /help prints and what the completion menu offers, so
22+ // none of the three can drift away from the others.
23+ var builtins = []command{
24+ {name: "help", desc: "the commands and the keys"},
25+ {name: "skills", desc: "the instruction sets found at startup"},
26+ {name: "effort", args: "level", desc: "how hard the model thinks: low … max"},
27+ {name: "clear", desc: "empty the transcript"},
28+ {name: "allowed", desc: "what is permanently allowed this session"},
29+ {name: "exit", desc: "leave the harness"},
30+ }
31+
32+ // commands is the catalogue: the built-ins, then every discovered skill.
33+ func (m *model) commands() []command {
34+ out := make([]command, 0, len(builtins)+len(m.opts.Skills))
35+ out = append(out, builtins...)
36+ for _, s := range m.opts.Skills {
37+ desc := s.Description
38+ if desc == "" {
39+ desc = "a skill with no description"
40+ }
41+ out = append(out, command{name: s.Name, args: "arguments", desc: desc, tag: s.Source})
42+ }
43+ return out
44+ }
45+
46+ // matchCommands narrows the catalogue to what has been typed after the slash.
47+ // A name that starts with it comes before one that merely contains it, so
48+ // "/sk" offers /skills before a skill called "ask-the-docs".
49+ func matchCommands(all []command, typed string) []command {
50+ if typed == "" {
51+ return append([]command(nil), all...)
52+ }
53+ var prefix, contains []command
54+ for _, c := range all {
55+ switch {
56+ case strings.HasPrefix(c.name, typed):
57+ prefix = append(prefix, c)
58+ case strings.Contains(c.name, typed):
59+ contains = append(contains, c)
60+ }
61+ }
62+ return append(prefix, contains...)
63+ }
64+
65+ // helpText is built from the catalogue rather than written out beside it.
66+ func (m *model) helpText() string {
67+ var b strings.Builder
68+ b.WriteString("commands:")
69+ for _, c := range builtins {
70+ name := "/" + c.name
71+ if c.args != "" {
72+ name += " <" + c.args + ">"
73+ }
74+ fmt.Fprintf(&b, "\n %-18s %s", name, c.desc)
75+ }
76+ if n := len(m.opts.Skills); n > 0 {
77+ fmt.Fprintf(&b, "\n %-18s %s", "/<skill>", plural(n, "discovered skill")+" — /skills lists them")
78+ }
79+ b.WriteString("\nkeys: enter send · ctrl+j newline · shift+tab cycle approval mode" +
80+ "\n / opens the command menu · ↑↓ move · tab or enter complete · esc dismiss" +
81+ "\n ctrl+r toggle full tool output · pgup/pgdn scroll" +
82+ "\n esc or ctrl+c interrupt a turn · ctrl+c again to exit")
83+ return b.String()
84+ }
+175−0internal/tui/complete.goaddedView File
@@ -0,0 +1,175 @@
1+ package tui
2+
3+ import (
4+ "fmt"
5+ "strings"
6+
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/lipgloss"
9+ )
10+
11+ // menuMaxRows caps the completion list. Past about eight rows the menu is
12+ // eating the transcript, and a list that long is faster to filter than to
13+ // scroll anyway.
14+ const menuMaxRows = 8
15+
16+ // menu is the slash-command completion list under the prompt. It is closed
17+ // when it holds no items; there is no separate flag to disagree with that.
18+ type menu struct {
19+ items []command
20+ idx int
21+ // dismissed is the prompt text the menu was last closed on. Accepting a
22+ // completion leaves "/clear" in the box, which is exactly the text that
23+ // would re-open the menu, so closing remembers what it closed on and
24+ // stays shut until something else is typed.
25+ dismissed string
26+ }
27+
28+ func (m *model) menuOpen() bool { return len(m.menu.items) > 0 }
29+
30+ // menuQuery reports what has been typed after the slash, and whether a
31+ // command is being typed at all. A slash anywhere but the start is prose; a
32+ // space means the name is settled and what follows is its arguments.
33+ func menuQuery(value string) (string, bool) {
34+ if !strings.HasPrefix(value, "/") {
35+ return "", false
36+ }
37+ rest := value[1:]
38+ if strings.ContainsAny(rest, " \t\n") {
39+ return "", false
40+ }
41+ return strings.ToLower(rest), true
42+ }
43+
44+ // refreshMenu recomputes the list from what is in the prompt box. It runs
45+ // after every keystroke the textarea sees, so the list follows the text
46+ // rather than being opened and closed by particular keys.
47+ func (m *model) refreshMenu() {
48+ value := m.input.Value()
49+ typed, typing := menuQuery(value)
50+ if !typing || value == m.menu.dismissed || m.phase != phaseIdle {
51+ m.menu.items, m.menu.idx = nil, 0
52+ return
53+ }
54+
55+ // Keep the highlight on the command it was on while the list narrows
56+ // around it; otherwise start at the top.
57+ var was string
58+ if m.menuOpen() && m.menu.idx < len(m.menu.items) {
59+ was = m.menu.items[m.menu.idx].name
60+ }
61+ m.menu.items, m.menu.idx = matchCommands(m.commands(), typed), 0
62+ for i, c := range m.menu.items {
63+ if c.name == was {
64+ m.menu.idx = i
65+ }
66+ }
67+ }
68+
69+ func (m *model) closeMenu() {
70+ m.menu.items, m.menu.idx = nil, 0
71+ m.menu.dismissed = m.input.Value()
72+ }
73+
74+ // onMenuKey handles the keys that belong to an open menu, and reports whether
75+ // it took the key. Everything it does not take falls through to the prompt,
76+ // which is what keeps typing a filter rather than a command of its own.
77+ func (m *model) onMenuKey(msg tea.KeyMsg) (tea.Cmd, bool) {
78+ n := len(m.menu.items)
79+ switch msg.String() {
80+ case "up", "ctrl+p":
81+ m.menu.idx = (m.menu.idx - 1 + n) % n
82+ return nil, true
83+ case "down", "ctrl+n":
84+ m.menu.idx = (m.menu.idx + 1) % n
85+ return nil, true
86+ case "tab", "enter":
87+ m.acceptCompletion()
88+ return nil, true
89+ case "esc":
90+ m.closeMenu()
91+ return nil, true
92+ }
93+ return nil, false
94+ }
95+
96+ // acceptCompletion puts the highlighted command in the prompt box. It does
97+ // not send it: a command that takes arguments is half-typed at this point, and
98+ // one that does not is one more enter away.
99+ func (m *model) acceptCompletion() {
100+ c := m.menu.items[m.menu.idx]
101+ text := "/" + c.name
102+ if c.args != "" {
103+ text += " "
104+ }
105+ m.input.SetValue(text)
106+ m.input.CursorEnd()
107+ m.closeMenu()
108+ m.growInput()
109+ }
110+
111+ // menuRows is how many lines the menu may take: what is left after the rest of
112+ // the bottom chrome and one row of transcript, capped.
113+ func (m *model) menuRows() int {
114+ // activity line, the prompt box's three, the status line, the mode line,
115+ // and a row of transcript to keep the menu from swallowing the screen.
116+ room := m.height - 6 - m.input.Height()
117+ return maxInt(1, minInt(menuMaxRows, room))
118+ }
119+
120+ // menuView renders the list under the prompt: the highlighted row in coral
121+ // with its description beside it, the rest dimmed.
122+ func (m *model) menuView() string {
123+ if !m.menuOpen() {
124+ return ""
125+ }
126+ rows := m.menuRows()
127+ shown := rows
128+ if len(m.menu.items) > rows {
129+ // The last row goes to the count of what did not fit.
130+ shown = maxInt(1, rows-1)
131+ }
132+ start := 0
133+ if m.menu.idx >= shown {
134+ start = m.menu.idx - shown + 1
135+ }
136+ end := minInt(len(m.menu.items), start+shown)
137+
138+ // One column for the names, so the descriptions line up. A long skill
139+ // name is left to overflow its column rather than being truncated: the
140+ // name is the thing you are picking, and half of it is no use.
141+ width := 0
142+ for _, c := range m.menu.items[start:end] {
143+ width = maxInt(width, lipgloss.Width("/"+c.name))
144+ }
145+ width = minInt(width, maxInt(12, m.width/3))
146+
147+ var b strings.Builder
148+ for i := start; i < end; i++ {
149+ c := m.menu.items[i]
150+ name := pad("/"+c.name, width)
151+ desc := c.desc
152+ if c.tag != "" {
153+ desc += " (" + c.tag + ")"
154+ }
155+ var line string
156+ if i == m.menu.idx {
157+ line = styKey.Render("❯ "+name) + " " + styText.Render(desc)
158+ } else {
159+ line = " " + styDim.Render(name) + " " + styFaint.Render(desc)
160+ }
161+ b.WriteString(truncateStyled(" "+line, maxInt(1, m.width)) + "\n")
162+ }
163+ if n := len(m.menu.items) - (end - start); n > 0 {
164+ b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), maxInt(1, m.width)) + "\n")
165+ }
166+ return strings.TrimRight(b.String(), "\n")
167+ }
168+
169+ // pad widens s to width cells, leaving anything already wider alone.
170+ func pad(s string, width int) string {
171+ if n := width - lipgloss.Width(s); n > 0 {
172+ return s + strings.Repeat(" ", n)
173+ }
174+ return s
175+ }
+222−0internal/tui/complete_test.goaddedView File
@@ -0,0 +1,222 @@
1+ package tui
2+
3+ import (
4+ "strings"
5+ "testing"
6+
7+ tea "github.com/charmbracelet/bubbletea"
8+
9+ "harness/internal/skills"
10+ )
11+
12+ // The menu belongs to a slash command being typed, and to nothing else: a
13+ // message that merely mentions one must not open it.
14+ func TestMenuQuery(t *testing.T) {
15+ cases := []struct {
16+ in string
17+ want string
18+ wantOpen bool
19+ }{
20+ {"/", "", true},
21+ {"/ef", "ef", true},
22+ {"/EF", "ef", true},
23+ {"", "", false},
24+ {"hello", "", false},
25+ {"see /help for that", "", false},
26+ {"/effort high", "", false}, // the name is settled; the menu is done
27+ {"/help\nmore", "", false}, // a second line means a message, not a command
28+ {" /help", "", false},
29+ }
30+ for _, tc := range cases {
31+ got, open := menuQuery(tc.in)
32+ if open != tc.wantOpen || got != tc.want {
33+ t.Errorf("menuQuery(%q) = %q, %v; want %q, %v", tc.in, got, open, tc.want, tc.wantOpen)
34+ }
35+ }
36+ }
37+
38+ func names(cmds []command) []string {
39+ var out []string
40+ for _, c := range cmds {
41+ out = append(out, c.name)
42+ }
43+ return out
44+ }
45+
46+ // Typing narrows the list, and a name that starts with what was typed beats
47+ // one that merely contains it.
48+ func TestMatchCommandsRanksPrefixesFirst(t *testing.T) {
49+ all := []command{
50+ {name: "allowed"},
51+ {name: "clear"},
52+ {name: "effort"},
53+ {name: "reformat-everything"},
54+ }
55+ cases := map[string][]string{
56+ "": {"allowed", "clear", "effort", "reformat-everything"},
57+ "e": {"effort", "allowed", "clear", "reformat-everything"},
58+ "ef": {"effort", "reformat-everything"},
59+ "format": {"reformat-everything"},
60+ "zz": nil,
61+ }
62+ for typed, want := range cases {
63+ got := names(matchCommands(all, typed))
64+ if strings.Join(got, ",") != strings.Join(want, ",") {
65+ t.Errorf("matchCommands(%q) = %v, want %v", typed, got, want)
66+ }
67+ }
68+ }
69+
70+ // The catalogue is the union of what the TUI answers itself and what the
71+ // session discovered, so a skill is reachable from the menu by name.
72+ func TestCommandsListBuiltinsAndSkills(t *testing.T) {
73+ m := newTestModel(t)
74+ m.opts.Skills = []skills.Skill{
75+ {Name: "brainstorming", Description: "explore an idea", Source: "personal"},
76+ }
77+ got := names(m.commands())
78+ for _, want := range []string{"help", "effort", "skills", "exit", "brainstorming"} {
79+ if !strings.Contains(strings.Join(got, ","), want) {
80+ t.Errorf("command %q missing from the catalogue %v", want, got)
81+ }
82+ }
83+ for _, c := range m.commands() {
84+ if c.desc == "" {
85+ t.Errorf("command /%s has no description to show in the menu", c.name)
86+ }
87+ }
88+ }
89+
90+ // Every name the menu offers must be one submit actually answers; otherwise
91+ // the menu is advertising commands that report themselves as unknown.
92+ func TestEveryOfferedCommandIsHandled(t *testing.T) {
93+ catalogue := newTestModel(t).commands()
94+ for _, c := range catalogue {
95+ m := newTestModel(t)
96+ m.opts.Skills = []skills.Skill{{Name: "brainstorming", Description: "explore an idea"}}
97+ m.submit("/" + c.name)
98+ if out := m.tr.render(); strings.Contains(out, "unknown command") {
99+ t.Errorf("/%s is offered by the menu but not handled: %q", c.name, out)
100+ }
101+ }
102+ }
103+
104+ // Moving and accepting: the highlighted name lands in the prompt box, and
105+ // accepting is not sending — the turn starts on the next enter.
106+ func TestMenuCompletesWithoutSending(t *testing.T) {
107+ m := newTestModel(t)
108+ typeIn(m, "/")
109+ if !m.menuOpen() {
110+ t.Fatal("typing a slash did not open the menu")
111+ }
112+ first := m.menu.items[0].name
113+
114+ m.Update(key("down"))
115+ second := m.menu.items[m.menu.idx].name
116+ if second == first {
117+ t.Fatal("down did not move the highlight")
118+ }
119+
120+ m.Update(key("enter"))
121+ if m.menuOpen() {
122+ t.Error("the menu stayed open after accepting")
123+ }
124+ if got := m.input.Value(); !strings.HasPrefix(got, "/"+second) {
125+ t.Errorf("input = %q, want it completed to /%s", got, second)
126+ }
127+ if m.phase != phaseIdle {
128+ t.Error("accepting a completion started a turn; it should only fill the box")
129+ }
130+ }
131+
132+ // A command that takes arguments is completed with the space already typed;
133+ // one that does not is completed bare and the menu does not re-open on it.
134+ func TestCompletionTrailingSpaceFollowsArguments(t *testing.T) {
135+ m := newTestModel(t)
136+ m.opts.Skills = []skills.Skill{{Name: "brainstorming", Description: "explore an idea"}}
137+
138+ typeIn(m, "/brainstorm")
139+ m.Update(key("tab"))
140+ if got := m.input.Value(); got != "/brainstorming " {
141+ t.Errorf("input = %q, want a trailing space for a command that takes arguments", got)
142+ }
143+
144+ m2 := newTestModel(t)
145+ typeIn(m2, "/clear")
146+ m2.Update(key("tab"))
147+ if got := m2.input.Value(); got != "/clear" {
148+ t.Errorf("input = %q, want no trailing space on an argument-free command", got)
149+ }
150+ if m2.menuOpen() {
151+ t.Error("the menu re-opened on the text it had just completed")
152+ }
153+ }
154+
155+ // Escape is for dismissing the menu, and only that: the text survives.
156+ func TestEscapeClosesMenuKeepingText(t *testing.T) {
157+ m := newTestModel(t)
158+ typeIn(m, "/help")
159+ if !m.menuOpen() {
160+ t.Fatal("menu not open")
161+ }
162+
163+ m.Update(key("esc"))
164+ if m.menuOpen() {
165+ t.Error("escape did not close the menu")
166+ }
167+ if got := m.input.Value(); got != "/help" {
168+ t.Errorf("input = %q, want the typed text left alone", got)
169+ }
170+
171+ // With the menu gone, enter sends what was typed.
172+ m.Update(key("enter"))
173+ if !strings.Contains(m.tr.render(), "commands:") {
174+ t.Error("enter after dismissing the menu did not run the command")
175+ }
176+ }
177+
178+ // Typing past the last match closes the menu rather than showing an empty box.
179+ func TestMenuClosesWhenNothingMatches(t *testing.T) {
180+ m := newTestModel(t)
181+ typeIn(m, "/zzzq")
182+ if m.menuOpen() {
183+ t.Errorf("menu open with no matches: %v", names(m.menu.items))
184+ }
185+ }
186+
187+ // The menu sits in the bottom chrome, which must still leave the transcript
188+ // somewhere to live on a short terminal.
189+ func TestMenuFitsTheTerminal(t *testing.T) {
190+ for _, h := range []int{10, 24, 60} {
191+ m := newTestModel(t)
192+ m.Update(tea.WindowSizeMsg{Width: 80, Height: h})
193+ typeIn(m, "/")
194+ if !m.menuOpen() {
195+ t.Fatalf("height %d: menu did not open", h)
196+ }
197+ view := m.View()
198+ if got := strings.Count(view, "\n") + 1; got > h {
199+ t.Errorf("height %d: view is %d lines, one row of the menu too many", h, got)
200+ }
201+ if !strings.Contains(view, "/help") {
202+ t.Errorf("height %d: the menu rendered nothing", h)
203+ }
204+ }
205+ }
206+
207+ // While the agent has the turn, enter is refused and esc means interrupt, so
208+ // a menu opened under it would offer keys that do not do what it says.
209+ func TestMenuStaysShutWhileTheAgentWorks(t *testing.T) {
210+ m := newTestModel(t)
211+ m.phase = phaseRunning
212+
213+ typeIn(m, "/he")
214+
215+ if m.menuOpen() {
216+ t.Error("the menu opened during a turn")
217+ }
218+ m.Update(key("esc"))
219+ if !m.interrupting {
220+ t.Error("esc was eaten by the menu instead of interrupting the turn")
221+ }
222+ }
+56−6internal/tui/model.goView File
@@ -88,6 +88,9 @@ type model struct {
8888 // open with the same word.
8989 verbSeed int
9090
91+ // menu is the slash-command completion list under the prompt.
92+ menu menu
93+
9194 // pending approvals. The agent blocks on one at a time, but a queue costs
9295 // nothing and means a second request can never be dropped.
9396 pending []approvalMsg
@@ -331,6 +334,14 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
331334 return m.onApprovalKey(msg)
332335 }
333336
337+ // An open menu owns the keys that drive it — including enter and esc,
338+ // which mean something else the rest of the time.
339+ if m.menuOpen() {
340+ if cmd, took := m.onMenuKey(msg); took {
341+ return m, cmd
342+ }
343+ }
344+
334345 switch msg.String() {
335346 case "ctrl+c":
336347 // While a turn runs, ctrl-c interrupts it and leaves the session
@@ -378,12 +389,14 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
378389 }
379390 m.input.Reset()
380391 m.growInput()
392+ m.closeMenu()
381393 return m.submit(line)
382394 }
383395
384396 var cmd tea.Cmd
385397 m.input, cmd = m.input.Update(msg)
386398 m.growInput()
399+ m.refreshMenu()
387400 return m, cmd
388401 }
389402
@@ -433,9 +446,12 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
433446 return m, tea.Quit
434447
435448 case line == "/help":
436- m.tr.addNotice(helpText)
449+ m.tr.addNotice(m.helpText())
437450 return m, m.sync()
438451
452+ case line == "/effort" || strings.HasPrefix(line, "/effort "):
453+ return m, m.setEffort(strings.TrimSpace(strings.TrimPrefix(line, "/effort")))
454+
439455 case line == "/clear":
440456 m.tr.clear()
441457 return m, m.sync()
@@ -475,6 +491,42 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
475491 return m, tea.Batch(m.sync(), m.spin.Tick, m.runTurn(line))
476492 }
477493
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.
496+ func (m *model) setEffort(level string) tea.Cmd {
497+ switch {
498+ case level == "":
499+ m.tr.addNotice("effort: " + effortListing(m.opts.Effort))
500+ case !llm.ValidEffort(level):
501+ m.tr.addError("unknown effort " + level + " — one of " + strings.Join(llm.EffortLevels, ", "))
502+ 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")
508+ }
509+ return m.sync()
510+ }
511+
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+
478530 // isSkill reports whether a slash command names a discovered skill. Matching
479531 // here rather than sending every unrecognised slash to the model means a typo
480532 // is caught by the harness instead of puzzled over by the agent.
@@ -576,11 +628,6 @@ func matches(keys []string, key string) bool {
576628
577629 // ---- view ---------------------------------------------------------------
578630
579- const helpText = `commands: /allowed /clear /help /skills /exit
580- keys: enter send · ctrl+j newline · shift+tab cycle approval mode
581- ctrl+r toggle full tool output · pgup/pgdn scroll
582- esc or ctrl+c interrupt a turn · ctrl+c again to exit`
583-
584631 func (m *model) View() string {
585632 if !m.ready {
586633 return "starting…"
@@ -610,6 +657,9 @@ func (m *model) bottom() string {
610657 b.WriteString(m.approvalView() + "\n")
611658 } else {
612659 b.WriteString(m.inputBox() + "\n")
660+ if v := m.menuView(); v != "" {
661+ b.WriteString(v + "\n")
662+ }
613663 }
614664 b.WriteString(m.status() + "\n")
615665 b.WriteString(m.modeLine())
+67−0internal/tui/model_test.goView File
@@ -37,10 +37,24 @@ func key(s string) tea.KeyMsg {
3737 return tea.KeyMsg{Type: tea.KeyCtrlR}
3838 case "shift+tab":
3939 return tea.KeyMsg{Type: tea.KeyShiftTab}
40+ case "tab":
41+ return tea.KeyMsg{Type: tea.KeyTab}
42+ case "up":
43+ return tea.KeyMsg{Type: tea.KeyUp}
44+ case "down":
45+ return tea.KeyMsg{Type: tea.KeyDown}
4046 }
4147 return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
4248 }
4349
50+ // typeIn sends a string one keystroke at a time, the way a person produces
51+ // it: the completion menu reacts to keys, not to the value being assigned.
52+ func typeIn(m *model, s string) {
53+ for _, r := range s {
54+ m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
55+ }
56+ }
57+
4458 // pend queues an approval the way the bridge would, and returns the channel
4559 // the model is expected to answer on.
4660 func pend(m *model, req approval.Request) chan approval.Decision {
@@ -417,3 +431,56 @@ func TestViewShowsApprovalOptionsConditionally(t *testing.T) {
417431 t.Error("opaque command offered an option that would be a lie")
418432 }
419433 }
434+
435+ // /effort is the one command that changes how the next turn runs, so it has to
436+ // take, be visible in the chrome, and reach the agent.
437+ func TestEffortCommandSetsTheLevel(t *testing.T) {
438+ m := newTestModel(t)
439+ m.submit("/effort xhigh")
440+
441+ if m.opts.Effort != "xhigh" {
442+ t.Errorf("effort = %q, want xhigh", m.opts.Effort)
443+ }
444+ if !strings.Contains(m.activity(), "xhigh") {
445+ t.Errorf("the effort pill still reads %q", m.activity())
446+ }
447+ if !strings.Contains(m.tr.render(), "xhigh") {
448+ t.Error("the change was not reported in the transcript")
449+ }
450+ }
451+
452+ func TestEffortCommandRejectsAnUnknownLevel(t *testing.T) {
453+ m := newTestModel(t)
454+ m.opts.Effort = "high"
455+ m.submit("/effort ludicrous")
456+
457+ if m.opts.Effort != "high" {
458+ t.Errorf("effort = %q, want the previous level kept", m.opts.Effort)
459+ }
460+ out := m.tr.render()
461+ if !strings.Contains(out, "ludicrous") || !strings.Contains(out, "xhigh") {
462+ t.Errorf("want the bad level named and the good ones listed, got %q", out)
463+ }
464+ if m.phase != phaseIdle {
465+ t.Error("a rejected effort still started a turn")
466+ }
467+ }
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+ }