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>
Showing 10 changed files with 708 additions and 9 deletions.
| @@ -72,6 +72,7 @@ to six lines. | ||
| 72 | 72 | | Key | Does | |
| 73 | 73 | | --- | --- | |
| 74 | 74 | | `enter` | send | |
| 75 | + | `/` | open the command menu — see below | | |
| 75 | 76 | | `ctrl+j` | newline without sending | |
| 76 | 77 | | `shift+tab` | cycle the approval mode | |
| 77 | 78 | | `ctrl+r` | expand elided tool output | |
| @@ -79,7 +80,31 @@ to six lines. | ||
| 79 | 80 | | `esc` | interrupt the turn | |
| 80 | 81 | | `ctrl+c` | interrupt the turn; again when idle to exit | |
| 81 | 82 | |
| 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. | |
| 83 | 108 | |
| 84 | 109 | `shift+tab` cycles between asking before every mutating call and accepting them |
| 85 | 110 | all, which is `-yolo` reached from the keyboard. The mode line says which is in |
| @@ -6,6 +6,7 @@ import ( | ||
| 6 | 6 | "errors" |
| 7 | 7 | "fmt" |
| 8 | 8 | "strings" |
| 9 | + "sync" | |
| 9 | 10 | |
| 10 | 11 | "harness/internal/llm" |
| 11 | 12 | "harness/internal/metrics" |
| @@ -126,6 +127,25 @@ type Agent struct { | ||
| 126 | 127 | // lastInputTokens is the size of the previous request as the API measured |
| 127 | 128 | // it, cached and uncached parts together. |
| 128 | 129 | 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 | |
| 129 | 149 | } |
| 130 | 150 | |
| 131 | 151 | // 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 { | ||
| 320 | 340 | Tools: defs, |
| 321 | 341 | Thinking: llm.Adaptive(), |
| 322 | 342 | } |
| 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} | |
| 325 | 345 | } |
| 326 | 346 | return req |
| 327 | 347 | } |
| @@ -527,3 +527,29 @@ func TestSystemPromptIsStableAcrossRequests(t *testing.T) { | ||
| 527 | 527 | t.Error("the system prompt changed between two requests in one session") |
| 528 | 528 | } |
| 529 | 529 | } |
| 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 | + } | |
| @@ -172,6 +172,21 @@ type OutputConfig struct { | ||
| 172 | 172 | Effort string `json:"effort,omitempty"` |
| 173 | 173 | } |
| 174 | 174 | |
| 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 | + | |
| 175 | 190 | // Request is the body of POST /v1/messages. |
| 176 | 191 | type Request struct { |
| 177 | 192 | Model string `json:"model"` |
| @@ -122,3 +122,18 @@ func TestResponseAccessors(t *testing.T) { | ||
| 122 | 122 | t.Errorf("Message() = %+v, want all 4 blocks under the assistant role", msg) |
| 123 | 123 | } |
| 124 | 124 | } |
| 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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -88,6 +88,9 @@ type model struct { | ||
| 88 | 88 | // open with the same word. |
| 89 | 89 | verbSeed int |
| 90 | 90 | |
| 91 | + // menu is the slash-command completion list under the prompt. | |
| 92 | + menu menu | |
| 93 | + | |
| 91 | 94 | // pending approvals. The agent blocks on one at a time, but a queue costs |
| 92 | 95 | // nothing and means a second request can never be dropped. |
| 93 | 96 | pending []approvalMsg |
| @@ -331,6 +334,14 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 331 | 334 | return m.onApprovalKey(msg) |
| 332 | 335 | } |
| 333 | 336 | |
| 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 | + | |
| 334 | 345 | switch msg.String() { |
| 335 | 346 | case "ctrl+c": |
| 336 | 347 | // 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) { | ||
| 378 | 389 | } |
| 379 | 390 | m.input.Reset() |
| 380 | 391 | m.growInput() |
| 392 | + m.closeMenu() | |
| 381 | 393 | return m.submit(line) |
| 382 | 394 | } |
| 383 | 395 | |
| 384 | 396 | var cmd tea.Cmd |
| 385 | 397 | m.input, cmd = m.input.Update(msg) |
| 386 | 398 | m.growInput() |
| 399 | + m.refreshMenu() | |
| 387 | 400 | return m, cmd |
| 388 | 401 | } |
| 389 | 402 | |
| @@ -433,9 +446,12 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) { | ||
| 433 | 446 | return m, tea.Quit |
| 434 | 447 | |
| 435 | 448 | case line == "/help": |
| 436 | - m.tr.addNotice(helpText) | |
| 449 | + m.tr.addNotice(m.helpText()) | |
| 437 | 450 | return m, m.sync() |
| 438 | 451 | |
| 452 | + case line == "/effort" || strings.HasPrefix(line, "/effort "): | |
| 453 | + return m, m.setEffort(strings.TrimSpace(strings.TrimPrefix(line, "/effort"))) | |
| 454 | + | |
| 439 | 455 | case line == "/clear": |
| 440 | 456 | m.tr.clear() |
| 441 | 457 | return m, m.sync() |
| @@ -475,6 +491,42 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) { | ||
| 475 | 491 | return m, tea.Batch(m.sync(), m.spin.Tick, m.runTurn(line)) |
| 476 | 492 | } |
| 477 | 493 | |
| 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 | + | |
| 478 | 530 | // isSkill reports whether a slash command names a discovered skill. Matching |
| 479 | 531 | // here rather than sending every unrecognised slash to the model means a typo |
| 480 | 532 | // is caught by the harness instead of puzzled over by the agent. |
| @@ -576,11 +628,6 @@ func matches(keys []string, key string) bool { | ||
| 576 | 628 | |
| 577 | 629 | // ---- view --------------------------------------------------------------- |
| 578 | 630 | |
| 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 | - | |
| 584 | 631 | func (m *model) View() string { |
| 585 | 632 | if !m.ready { |
| 586 | 633 | return "starting…" |
| @@ -610,6 +657,9 @@ func (m *model) bottom() string { | ||
| 610 | 657 | b.WriteString(m.approvalView() + "\n") |
| 611 | 658 | } else { |
| 612 | 659 | b.WriteString(m.inputBox() + "\n") |
| 660 | + if v := m.menuView(); v != "" { | |
| 661 | + b.WriteString(v + "\n") | |
| 662 | + } | |
| 613 | 663 | } |
| 614 | 664 | b.WriteString(m.status() + "\n") |
| 615 | 665 | b.WriteString(m.modeLine()) |
| @@ -37,10 +37,24 @@ func key(s string) tea.KeyMsg { | ||
| 37 | 37 | return tea.KeyMsg{Type: tea.KeyCtrlR} |
| 38 | 38 | case "shift+tab": |
| 39 | 39 | 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} | |
| 40 | 46 | } |
| 41 | 47 | return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} |
| 42 | 48 | } |
| 43 | 49 | |
| 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 | + | |
| 44 | 58 | // pend queues an approval the way the bridge would, and returns the channel |
| 45 | 59 | // the model is expected to answer on. |
| 46 | 60 | func pend(m *model, req approval.Request) chan approval.Decision { |
| @@ -417,3 +431,56 @@ func TestViewShowsApprovalOptionsConditionally(t *testing.T) { | ||
| 417 | 431 | t.Error("opaque command offered an option that would be a lie") |
| 418 | 432 | } |
| 419 | 433 | } |
| 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 | + } | |