Skip to content

Commit bc79b52

Browse files
committed
Rename /think to /effort and route it through an inline picker that lists only the reasoning levels the active model supports.
1 parent ecd0a9d commit bc79b52

19 files changed

Lines changed: 384 additions & 134 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to Sofos are documented in this file.
66

77
### Added
88

9+
- **`/think` is renamed to `/effort` and now opens an inline picker.** The picker lists only the reasoning-effort levels the active model supports — derived from the model's per-row entry in the registry, so an unsupported level can't be picked. Up and Down highlight a level, Enter confirms, Esc cancels. The direct form `/effort <level>` still works; the seven `/think <level>` shortcuts that used to clutter the command catalog are gone in favour of the one picker entry. **Breaking change**: `/think` is no longer recognised.
910
- **A new `/model` command opens an inline picker that lets you switch the active model.** Up and Down highlight an entry, Enter confirms, Esc cancels. Each row shows the model id, a short description, and a `(current)` tag on the model you are using right now. Models on the other provider are greyed out and tagged `(re-launch session to activate)`, because the underlying API client is built once at startup and cannot swap providers mid-session. The cursor skips greyed rows so you cannot land on a model the running session cannot reach. Typing `/model <name>` switches directly without opening the picker; same-provider switches happen in place, cross-provider attempts are refused with a clear "re-launch with `--model <name>`" message.
1011
- **`--model` now lists the supported models when it rejects a value.** Passing `--model gpt-9.9` (or any slug outside the supported set) exits with `[supported models: claude-opus-4-7, claude-sonnet-4-6, claude-haiku-4-5, gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex]`, mirroring the existing `--reasoning-effort` rejection so the failure mode is consistent.
1112
- **An inline suggestion list appears the moment you type `/`.** As soon as the input begins with a slash, a small panel below the input box lists every available slash command together with a short description, filtered by what you have typed so far. Up and Down highlight an entry, Enter runs it, Tab inserts it into the input so you can finish typing arguments, and Esc closes the panel. The Tab autocomplete that previously worked silently for a single match still does, but it now goes through the same suggestion list.

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,8 @@ sofos --resume
187187
| `/resume` | Open the session picker and resume a saved conversation. |
188188
| `/clear` | Clear the current conversation history and start a fresh session id. |
189189
| `/compact` | Compact older context to reduce token usage. |
190-
| `/think` | Show the current reasoning-effort setting. |
191-
| `/think off\|low\|medium\|high\|xhigh\|max` | Change reasoning effort when the active model supports the selected level. |
190+
| `/effort` | Open the reasoning-effort picker. The picker lists only the levels the active model supports. **Up / Down**, **Enter** to switch, **Esc** to cancel. |
191+
| `/effort off\|low\|medium\|high\|xhigh\|max` | Switch directly to a named level. Validation matches the picker — unsupported levels print a clear error. |
192192
| `/model` | Open the model picker. Highlight an entry with **Up / Down**, **Enter** to switch, **Esc** to cancel. Models on the other provider are greyed out (the API client is fixed at startup) and the cursor skips them. |
193193
| `/model <name>` | Switch directly to a named model without opening the picker. Same-provider only — cross-provider switches require relaunching with `--model <name>`. |
194194
| `/safe` | Enable safe mode: read-only native tools. Prompt changes to `:`. |
@@ -280,7 +280,7 @@ Sofos exposes six reasoning levels:
280280
off, low, medium, high, xhigh, max
281281
```
282282

283-
The active model determines which levels are accepted. Sofos validates the level at startup and when `/think` is used, so unsupported combinations fail before reaching the provider API.
283+
The active model determines which levels are accepted. Sofos validates the level at startup and when `/effort` is used, so unsupported combinations fail before reaching the provider API.
284284

285285
Examples:
286286

@@ -592,7 +592,7 @@ See [`RELEASE.md`](RELEASE.md) for the full process.
592592
|---|---|
593593
| API key error | Set `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`, or pass `--api-key` / `--openai-api-key`. |
594594
| Cannot connect | Run `sofos --check-connection`. |
595-
| Model rejects reasoning effort | Use `/think` or `-e` with a level supported by the selected model. |
595+
| Model rejects reasoning effort | Use `/effort` or `-e` with a level supported by the selected model. |
596596
| Path denied | Add a `Read`, `Write`, or `Bash` rule, or approve the interactive prompt. |
597597
| External edit denied | `edit_file` and `morph_edit_file` need both Read and Write for external files. |
598598
| Code search unavailable | Install `ripgrep` and ensure `rg` is on `PATH`. |

STRUCTURE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -611,7 +611,7 @@ It contains:
611611
- available-tool refresh;
612612
- one-shot prompt execution;
613613
- status-line snapshots;
614-
- `/think`, `/safe`, `/normal`, and `/clear` state handlers;
614+
- `/effort`, `/safe`, `/normal`, and `/clear` state handlers;
615615
- shared interrupt and mid-turn steering buffers.
616616

617617
Rules:
@@ -1305,7 +1305,8 @@ Built-in commands include:
13051305
- `/resume`;
13061306
- `/clear`;
13071307
- `/compact`;
1308-
- `/think`;
1308+
- `/effort`;
1309+
- `/model`;
13091310
- `/safe`;
13101311
- `/normal`;
13111312
- `/exit`, `/quit`, `/q`.

src/api/model_info.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ pub struct Model {
7272
/// running a client-side LLM-summary turn.
7373
pub supports_server_compaction: bool,
7474
/// Reasoning-effort levels this model accepts on the wire.
75-
/// Startup validation and the `/think` handler use this list to
75+
/// Startup validation and the `/effort` handler use this list to
7676
/// reject mismatched pairs (`xhigh` on Sonnet 4.6, `max` on any
7777
/// OpenAI model) before they reach the server.
7878
pub supported_efforts: &'static [ReasoningEffort],
@@ -369,7 +369,7 @@ pub fn lookup(name: &str) -> &'static Model {
369369
/// pair, or `None` if the pair is supported. The message names the
370370
/// model and lists every effort level the model does accept, so the
371371
/// user can pick a valid alternative without consulting the docs.
372-
/// Surfaced to the user from the startup validator and the `/think`
372+
/// Surfaced to the user from the startup validator and the `/effort`
373373
/// handler so the failure mode is the same in both places.
374374
pub fn effort_support_error(name: &str, effort: ReasoningEffort) -> Option<String> {
375375
let info = lookup(name);

src/api/types.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ impl Reasoning {
287287
/// and `Max` are the extra-capability rungs and have model-specific
288288
/// support — see [`crate::api::model_info::effort_support_error`] for
289289
/// the per-model matrix. Picking an unsupported combination is
290-
/// rejected at startup (in `main.rs`) and at the `/think` command, so
290+
/// rejected at startup (in `main.rs`) and at the `/effort` command, so
291291
/// the wire layer can assume every effort it sees is acceptable for
292292
/// the active model.
293293
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]

src/commands/builtin.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,18 @@ pub fn resume_command(repl: &mut Repl) -> Result<CommandResult> {
2323
Ok(CommandResult::Continue)
2424
}
2525

26-
pub fn think_set_command(
27-
repl: &mut Repl,
28-
effort: crate::api::ReasoningEffort,
29-
) -> Result<CommandResult> {
30-
repl.handle_think_set(effort);
26+
pub fn effort_picker_command(repl: &mut Repl) -> Result<CommandResult> {
27+
// The TUI worker intercepts this and opens the inline picker;
28+
// this fallback only runs in non-interactive mode.
29+
repl.handle_effort_picker_fallback();
3130
Ok(CommandResult::Continue)
3231
}
3332

34-
pub fn think_status_command(repl: &mut Repl) -> Result<CommandResult> {
35-
repl.handle_think_status();
33+
pub fn effort_set_command(
34+
repl: &mut Repl,
35+
effort: crate::api::ReasoningEffort,
36+
) -> Result<CommandResult> {
37+
repl.handle_effort_set(effort);
3638
Ok(CommandResult::Continue)
3739
}
3840

src/commands/mod.rs

Lines changed: 45 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ pub enum Command {
1818
Exit,
1919
Clear,
2020
Resume,
21-
ThinkSet(crate::api::ReasoningEffort),
22-
ThinkStatus,
21+
/// `/effort` — open the reasoning-effort picker.
22+
EffortPicker,
23+
/// `/effort <level>` — set the level directly. Per-model
24+
/// validation matches `--reasoning-effort`.
25+
EffortSet(crate::api::ReasoningEffort),
2326
SafeMode,
2427
NormalMode,
2528
Compact,
@@ -39,14 +42,19 @@ impl Command {
3942
"/exit" | "/quit" | "/q" => Some(Command::Exit),
4043
"/clear" => Some(Command::Clear),
4144
"/resume" => Some(Command::Resume),
42-
"/think" => Some(Command::ThinkStatus),
45+
"/effort" => Some(Command::EffortPicker),
4346
"/safe" => Some(Command::SafeMode),
4447
"/normal" => Some(Command::NormalMode),
4548
"/compact" => Some(Command::Compact),
4649
"/model" => Some(Command::ModelPicker),
4750
_ => {
48-
if let Some(arg) = lower.strip_prefix("/think ") {
49-
crate::api::ReasoningEffort::parse(arg).map(Command::ThinkSet)
51+
if let Some(arg) = lower.strip_prefix("/effort ") {
52+
let trimmed = arg.trim();
53+
if trimmed.is_empty() {
54+
Some(Command::EffortPicker)
55+
} else {
56+
crate::api::ReasoningEffort::parse(trimmed).map(Command::EffortSet)
57+
}
5058
} else if let Some(arg) = lower.strip_prefix("/model ") {
5159
let trimmed = arg.trim();
5260
if trimmed.is_empty() {
@@ -66,8 +74,8 @@ impl Command {
6674
Command::Exit => builtin::exit_command(repl),
6775
Command::Clear => builtin::clear_command(repl),
6876
Command::Resume => builtin::resume_command(repl),
69-
Command::ThinkSet(effort) => builtin::think_set_command(repl, *effort),
70-
Command::ThinkStatus => builtin::think_status_command(repl),
77+
Command::EffortPicker => builtin::effort_picker_command(repl),
78+
Command::EffortSet(effort) => builtin::effort_set_command(repl, *effort),
7179
Command::SafeMode => builtin::safe_mode_command(repl),
7280
Command::NormalMode => builtin::normal_mode_command(repl),
7381
Command::Compact => builtin::compact_command(repl),
@@ -104,32 +112,8 @@ pub static COMMAND_CATALOG: &[CommandEntry] = &[
104112
description: "resume a previously saved session",
105113
},
106114
CommandEntry {
107-
name: "/think",
108-
description: "show the current reasoning effort",
109-
},
110-
CommandEntry {
111-
name: "/think off",
112-
description: "disable reasoning effort",
113-
},
114-
CommandEntry {
115-
name: "/think low",
116-
description: "set reasoning effort to low",
117-
},
118-
CommandEntry {
119-
name: "/think medium",
120-
description: "set reasoning effort to medium",
121-
},
122-
CommandEntry {
123-
name: "/think high",
124-
description: "set reasoning effort to high",
125-
},
126-
CommandEntry {
127-
name: "/think xhigh",
128-
description: "set reasoning effort to extra high",
129-
},
130-
CommandEntry {
131-
name: "/think max",
132-
description: "set reasoning effort to the maximum value",
115+
name: "/effort",
116+
description: "switch the reasoning effort (opens a picker)",
133117
},
134118
CommandEntry {
135119
name: "/model",
@@ -201,8 +185,34 @@ mod tests {
201185
}
202186
}
203187

204-
/// Every catalog name must parse back into a known `Command`, either
205-
/// directly or via the `/think <effort>` argument form.
188+
#[test]
189+
fn bare_slash_effort_opens_picker() {
190+
assert_eq!(Command::from_str("/effort"), Some(Command::EffortPicker));
191+
}
192+
193+
#[test]
194+
fn slash_effort_with_trailing_space_opens_picker() {
195+
assert_eq!(Command::from_str("/effort "), Some(Command::EffortPicker));
196+
}
197+
198+
#[test]
199+
fn slash_effort_with_level_parses_to_effort_set() {
200+
match Command::from_str("/effort high") {
201+
Some(Command::EffortSet(e)) => assert_eq!(e, crate::api::ReasoningEffort::High),
202+
other => panic!("expected EffortSet, got {other:?}"),
203+
}
204+
}
205+
206+
#[test]
207+
fn slash_effort_with_unknown_level_returns_none() {
208+
// Unlike `/model <name>`, the effort argument has a fixed
209+
// alphabet (off/low/medium/high/xhigh/max); anything else
210+
// can't be turned into a `ReasoningEffort` so we surface the
211+
// generic "unknown command" message instead of guessing.
212+
assert!(Command::from_str("/effort turbo").is_none());
213+
}
214+
215+
/// Every catalog name must parse back into a known `Command`.
206216
#[test]
207217
fn every_catalog_entry_parses() {
208218
for entry in COMMAND_CATALOG {

src/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ mod tests {
321321

322322
/// Regression: the validation message in `Repl::new` was rewritten
323323
/// (`thinking_budget >= max_tokens` → `max_tokens <= legacy thinking-
324-
/// budget ceiling`) when `/think` started picking budgets per-effort
324+
/// budget ceiling`) when `/effort` started picking budgets per-effort
325325
/// instead of from the inert flag. The classifier here must still
326326
/// recognise the new wording and surface a useful hint.
327327
#[test]

src/repl/mod.rs

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@ impl Repl {
260260
self.model_config.model.clone()
261261
}
262262

263+
pub fn current_reasoning_effort(&self) -> crate::api::ReasoningEffort {
264+
self.model_config.reasoning_effort
265+
}
266+
263267
/// Snapshot of the user-facing state displayed in the TUI status line.
264268
pub fn status_snapshot(&self) -> tui::event::StatusSnapshot {
265269
let effort = self.model_config.reasoning_effort;
@@ -358,17 +362,15 @@ impl Repl {
358362
}
359363

360364
/// True when the active model uses adaptive thinking (Opus 4.7,
361-
/// Sonnet 4.6). Shared by the three `/think` handlers and the
362-
/// status line so they don't drift apart.
365+
/// Sonnet 4.6).
363366
fn uses_adaptive_thinking(&self) -> bool {
364367
matches!(self.client, Anthropic(_))
365368
&& crate::api::anthropic::requires_adaptive_thinking(&self.model_config.model)
366369
}
367370

368-
/// Print the reasoning-state line shared by the `/think` handlers.
371+
/// Print the reasoning-state line shared by the `/effort` paths.
369372
/// Three flavours: adaptive effort, Anthropic manual budget, OpenAI
370-
/// reasoning effort. Writing this once keeps the wording identical
371-
/// across the commands.
373+
/// reasoning effort.
372374
fn print_reasoning_state(&self) {
373375
let effort = self.model_config.reasoning_effort;
374376
if self.uses_adaptive_thinking() {
@@ -379,8 +381,6 @@ impl Repl {
379381
);
380382
} else if matches!(self.client, Anthropic(_)) {
381383
if effort.is_enabled() {
382-
// Show the per-effort tier budget so the `/think`
383-
// output matches what hits the API.
384384
let budget = crate::api::anthropic::legacy_thinking_budget(effort);
385385
println!(
386386
"\n{} (budget: {} tokens)\n",
@@ -399,7 +399,7 @@ impl Repl {
399399
}
400400
}
401401

402-
pub fn handle_think_set(&mut self, effort: crate::api::ReasoningEffort) {
402+
pub fn handle_effort_set(&mut self, effort: crate::api::ReasoningEffort) {
403403
if let Some(msg) =
404404
crate::api::model_info::effort_support_error(&self.model_config.model, effort)
405405
{
@@ -412,6 +412,35 @@ impl Repl {
412412
self.print_reasoning_state();
413413
}
414414

415+
/// Non-interactive fallback for `/effort`. The TUI opens the
416+
/// picker; this path lists supported levels in `--prompt` mode.
417+
pub fn handle_effort_picker_fallback(&self) {
418+
let info = crate::api::model_info::lookup(&self.model_config.model);
419+
let current = self.model_config.reasoning_effort;
420+
println!();
421+
println!(
422+
"{} {}",
423+
"Current effort:".bright_green(),
424+
current.as_label().bright_white()
425+
);
426+
println!("{}", "Supported levels:".bright_cyan());
427+
for effort in info.supported_efforts {
428+
let marker = if *effort == current { "❯" } else { " " };
429+
println!(
430+
" {} {}",
431+
marker.bright_green(),
432+
effort.as_label().bright_white()
433+
);
434+
}
435+
println!();
436+
println!(
437+
"{}",
438+
"Use `/effort <level>` to switch, or open an interactive session for the picker."
439+
.dimmed()
440+
);
441+
println!();
442+
}
443+
415444
/// Switch the active model to `name`. Refuses unsupported slugs,
416445
/// cross-provider switches (we can't swap the constructed
417446
/// [`LlmClient`] mid-session without re-reading API keys), and
@@ -460,7 +489,7 @@ impl Repl {
460489
) {
461490
println!();
462491
UI::print_error(&format!(
463-
"{} Run `/think <effort>` to pick a supported level before switching.",
492+
"{} Run `/effort <level>` to pick a supported level before switching.",
464493
msg
465494
));
466495
println!();
@@ -515,10 +544,6 @@ impl Repl {
515544
println!();
516545
}
517546

518-
pub fn handle_think_status(&self) {
519-
self.print_reasoning_state();
520-
}
521-
522547
pub fn enable_safe_mode(&mut self) {
523548
if self.safe_mode {
524549
println!("\n{}\n", "Safe mode: already enabled".dimmed());

src/repl/request_builder.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ impl<'a> RequestBuilder<'a> {
7272
};
7373

7474
let reasoning_config = if matches!(self.client, OpenAI(_)) {
75-
// `Max` is rejected upstream (startup validation + `/think`
75+
// `Max` is rejected upstream (startup validation + `/effort`
7676
// gate) because OpenAI's wire schema doesn't accept it.
7777
// Clamping `Max` defensively to the highest accepted level
7878
// here keeps the request well-formed if validation is ever
@@ -527,7 +527,7 @@ mod tests {
527527
#[test]
528528
fn legacy_anthropic_thinking_budget_scales_with_effort() {
529529
// On non-adaptive Anthropic models (Haiku 4.5),
530-
// `/think low|medium|high` used to all collapse to the same
530+
// `/effort low|medium|high` used to all collapse to the same
531531
// `thinking_budget`. Verify each tier now produces a strictly
532532
// larger budget so the slider has a visible effect.
533533
let conv = ConversationHistory::new();

0 commit comments

Comments
 (0)