feat(tui): lock mode/thinking while a turn is running (#2982)

While a turn is in flight (`is_loading`), the model/permission surface the
engine is acting on must not shift underneath it. User-initiated mode cycling
(cycle_mode / cycle_mode_reverse) and thinking cycling (cycle_effort) are now
refused with a concise status message ("… is locked while a turn is running —
press Esc to interrupt first") and the selection stays put (the chip "twitches"
back rather than moving). Internal set_mode transitions (YOLO/plan) are
unaffected — only the user gestures are guarded.

Unit-tested: locked while is_loading, works again once idle. Persistent
busy/free chip styling is left for a visual-review pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter B
2026-06-13 02:05:52 -07:00
parent 3246ef067c
commit 04aa64d569
+62
View File
@@ -2566,8 +2566,30 @@ impl App {
true
}
/// Whether mode/thinking selection is locked because a turn is in flight.
///
/// While `is_loading`, the model/permission surface the engine is acting on
/// must not shift underneath it, so user-initiated mode and thinking changes
/// are refused (#2982). Returns true (and posts a concise status message) if
/// the change should be rejected — the caller leaves the selection unchanged
/// so the chip "twitches" back instead of moving.
fn reject_setting_change_while_busy(&mut self, what: &str) -> bool {
if self.is_loading {
self.status_message = Some(format!(
"{what} is locked while a turn is running — press Esc to interrupt first"
));
self.needs_redraw = true;
true
} else {
false
}
}
/// Cycle through modes: Plan → Agent → YOLO → Plan.
pub fn cycle_mode(&mut self) {
if self.reject_setting_change_while_busy("Mode") {
return;
}
let next = match self.mode {
AppMode::Plan => AppMode::Agent,
AppMode::Agent => AppMode::Yolo,
@@ -2579,6 +2601,9 @@ impl App {
/// Cycle through modes in reverse.
#[allow(dead_code)]
pub fn cycle_mode_reverse(&mut self) {
if self.reject_setting_change_while_busy("Mode") {
return;
}
let next = match self.mode {
AppMode::Agent => AppMode::Plan,
AppMode::Yolo => AppMode::Agent,
@@ -2589,6 +2614,9 @@ impl App {
/// Cycle reasoning-effort through the active provider's distinct tiers.
pub fn cycle_effort(&mut self) {
if self.reject_setting_change_while_busy("Thinking") {
return;
}
self.reasoning_effort = self
.reasoning_effort
.cycle_next_for_provider(self.api_provider);
@@ -5580,6 +5608,40 @@ mod tests {
assert_eq!(app.reasoning_effort_display_label(), "auto: xhigh");
}
#[test]
fn mode_and_thinking_are_locked_while_a_turn_is_running() {
// #2982: while a turn is in flight, user-initiated mode/thinking changes
// are refused with a concise message instead of shifting the surface the
// engine is acting on.
let mut app = App::new(test_options(false), &Config::default());
app.mode = AppMode::Agent;
app.reasoning_effort = ReasoningEffort::Max;
app.is_loading = true;
app.cycle_mode();
assert_eq!(app.mode, AppMode::Agent, "mode must not change while busy");
assert!(
app.status_message
.as_deref()
.unwrap_or_default()
.contains("locked"),
"expected a 'locked' status message, got {:?}",
app.status_message
);
let before_effort = app.reasoning_effort;
app.cycle_effort();
assert_eq!(
app.reasoning_effort, before_effort,
"thinking must not change while busy"
);
// Once the turn finishes, the same gesture works again.
app.is_loading = false;
app.cycle_mode();
assert_ne!(app.mode, AppMode::Agent, "mode should change when idle");
}
#[test]
fn reasoning_effort_api_values_are_provider_aware_for_codex() {
assert_eq!(