From afc69b0c64704f6171a31837ed944d06d29d79a8 Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Fri, 9 Oct 2026 19:59:18 +0800 Subject: [PATCH 1/4] fix(tui): name the work that blocks a session switch A session-switch command refused because runtime work is active reported only that it was refused. In the session that prompted this, the real blocker was one background script that had been spinning for five hours, and the refusal never named it; `/new --force` was offered as if it were the way out though it only discards draft or queued input. session_transition_blockers() renders the same predicates as session_transition_blocked() in operator terms, and the boolean now derives from that list so the gate and its explanation cannot drift. Task rows carry the id, status, duration and a bounded summary, at most five, then a remainder line. Both session facets gain transition_blockers(); the lifecycle adapter and the control adapter forward to the App. The five lifecycle commands share one renderer, transition_blocked_message(), so a new blocker kind changes one place. The gate's decision logic is unchanged. --- crates/command-contract/src/facets.rs | 11 ++ crates/command-contract/src/tests.rs | 6 + crates/tui/src/commands/contract.rs | 8 ++ .../tui/src/commands/groups/session/branch.rs | 10 +- .../groups/session/control_test_support.rs | 5 + .../tui/src/commands/groups/session/fork.rs | 18 +-- .../session/lifecycle_portable_tests.rs | 14 ++- .../groups/session/lifecycle_test_support.rs | 5 + .../tui/src/commands/groups/session/load.rs | 10 +- crates/tui/src/commands/groups/session/mod.rs | 17 +++ crates/tui/src/commands/groups/session/new.rs | 10 +- .../tui/src/commands/groups/session/resume.rs | 16 +-- .../session_lifecycle_regression_tests.rs | 11 +- crates/tui/src/tui/app.rs | 113 +++++++++++++++--- crates/tui/src/tui/ui/tests.rs | 68 +++++++++++ 15 files changed, 266 insertions(+), 56 deletions(-) diff --git a/crates/command-contract/src/facets.rs b/crates/command-contract/src/facets.rs index cafe02d78f..cf7a6d3fd0 100644 --- a/crates/command-contract/src/facets.rs +++ b/crates/command-contract/src/facets.rs @@ -1381,6 +1381,12 @@ pub trait CommandSessionLifecycleContext { /// never consult it in the baseline, so their paths must not either. fn transition_blocked(&self) -> bool; + /// Why the transition gate is closed, in operator terms; empty means the + /// transition is allowed. Consulted only after [`Self::transition_blocked`] + /// reports a block, so the refusal can name the work that holds the + /// session instead of restating that it is held. + fn transition_blockers(&self) -> Vec; + /// `/branch` with no argument: the current leaf when an active journaled /// session resolves, otherwise `None` (the baseline silently falls back /// to the usage message on this path). @@ -1636,6 +1642,11 @@ pub trait CommandSessionControlContext { /// Live transition gate consulted by `/resume` before any picker or I/O. fn transition_blocked(&self) -> bool; + /// Why the transition gate is closed, in operator terms; empty means the + /// transition is allowed. Consulted only after [`Self::transition_blocked`] + /// reports a block. + fn transition_blockers(&self) -> Vec; + /// `/relay`: authoritative semantic snapshot (workspace/mode/model/goal/ /// to-do/plan/compact-template). Unavailable sources are represented as /// explicit states, never panics. diff --git a/crates/command-contract/src/tests.rs b/crates/command-contract/src/tests.rs index e1e72c0231..436a6252bc 100644 --- a/crates/command-contract/src/tests.rs +++ b/crates/command-contract/src/tests.rs @@ -2235,6 +2235,9 @@ impl CommandSessionLifecycleContext for FakeLifecycle { fn transition_blocked(&self) -> bool { self.blocked } + fn transition_blockers(&self) -> Vec { + Vec::new() + } fn branch_current_leaf_hint(&self) -> Option { self.leaf_hint.clone() } @@ -2558,6 +2561,9 @@ impl CommandSessionControlContext for FakeControl { fn transition_blocked(&self) -> bool { self.blocked } + fn transition_blockers(&self) -> Vec { + Vec::new() + } fn relay_projection(&self) -> RelayProjection { self.relay .clone() diff --git a/crates/tui/src/commands/contract.rs b/crates/tui/src/commands/contract.rs index c3ca88944c..54afab0001 100644 --- a/crates/tui/src/commands/contract.rs +++ b/crates/tui/src/commands/contract.rs @@ -318,6 +318,10 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> { self.host.app.borrow().session_transition_blocked() } + fn transition_blockers(&self) -> Vec { + self.host.app.borrow().session_transition_blockers() + } + fn branch_current_leaf_hint(&self) -> Option { let app = self.host.app.borrow(); let session_id = app.current_session_id.as_deref()?; @@ -1052,6 +1056,10 @@ impl CommandSessionControlContext for SessionControlAdapter<'_> { self.host.app.borrow().session_transition_blocked() } + fn transition_blockers(&self) -> Vec { + self.host.app.borrow().session_transition_blockers() + } + fn relay_projection(&self) -> RelayProjection { let app = self.host.app.borrow(); let plan = match app.plan_state.try_lock() { diff --git a/crates/tui/src/commands/groups/session/branch.rs b/crates/tui/src/commands/groups/session/branch.rs index b18890e92b..400f046af3 100644 --- a/crates/tui/src/commands/groups/session/branch.rs +++ b/crates/tui/src/commands/groups/session/branch.rs @@ -1,4 +1,4 @@ -use super::CommandResult; +use super::{CommandResult, transition_blocked_message}; use codewhale_command_contract::facets::CommandSessionLifecycleContext; use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; @@ -54,10 +54,10 @@ pub(in crate::commands) fn branch_portable( arg: Option<&str>, ) -> CommandResult { if lifecycle.transition_blocked() { - return CommandResult::error( - "Cannot branch while runtime work is active. Wait for the turn to finish, or cancel it first." - .to_string(), - ); + return CommandResult::error(transition_blocked_message( + "branch", + &lifecycle.transition_blockers(), + )); } let Some(entry_id) = arg.map(str::trim).filter(|s| !s.is_empty()) else { if let Some(leaf) = lifecycle.branch_current_leaf_hint() { diff --git a/crates/tui/src/commands/groups/session/control_test_support.rs b/crates/tui/src/commands/groups/session/control_test_support.rs index 184ad91595..93e0ede91e 100644 --- a/crates/tui/src/commands/groups/session/control_test_support.rs +++ b/crates/tui/src/commands/groups/session/control_test_support.rs @@ -29,6 +29,7 @@ pub(crate) struct FakeControl { pub(crate) start_info: Option, pub(crate) stop_refusal: Option>, pub(crate) hosted: Option>, + pub(crate) blockers: Vec, pub(crate) calls: RefCell>, } @@ -55,6 +56,10 @@ impl CommandSessionControlContext for FakeControl { self.call("transition_blocked", None); self.blocked } + fn transition_blockers(&self) -> Vec { + self.call("transition_blockers", None); + self.blockers.clone() + } fn relay_projection(&self) -> RelayProjection { self.call("relay_projection", None); self.relay.clone().expect("unexpected relay_projection()") diff --git a/crates/tui/src/commands/groups/session/fork.rs b/crates/tui/src/commands/groups/session/fork.rs index 41df1c1144..1c5817387e 100644 --- a/crates/tui/src/commands/groups/session/fork.rs +++ b/crates/tui/src/commands/groups/session/fork.rs @@ -1,6 +1,6 @@ //! `/fork` command — interactive picker (#576) + direct fork. -use super::CommandResult; +use super::{CommandResult, transition_blocked_message}; use codewhale_command_contract::facets::CommandSessionLifecycleContext; use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; @@ -67,10 +67,10 @@ pub(in crate::commands) fn fork_portable( ); } if lifecycle.transition_blocked() { - return CommandResult::error( - "Cannot fork a session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work first." - .to_string(), - ); + return CommandResult::error(transition_blocked_message( + "fork a session", + &lifecycle.transition_blockers(), + )); } return match lifecycle.fork_from(a) { Ok(receipt) => CommandResult::with_message_and_action( @@ -84,10 +84,10 @@ pub(in crate::commands) fn fork_portable( }; } if lifecycle.transition_blocked() { - return CommandResult::error( - "Cannot fork a session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work first." - .to_string(), - ); + return CommandResult::error(transition_blocked_message( + "fork a session", + &lifecycle.transition_blockers(), + )); } match lifecycle.fork_active() { Ok(receipt) => CommandResult::with_message_and_action( diff --git a/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs b/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs index cade29dd5b..3e9ff0693b 100644 --- a/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs +++ b/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs @@ -52,13 +52,14 @@ fn branch_composes_exact_baseline_messages() { // Blocked transition first. let mut canned = CannedLifecycle { blocked: true, + blockers: vec!["shell_a3f2 running 5h 18m cw-leftovers.ps1".to_string()], ..CannedLifecycle::default() }; let result = super::branch::branch_portable(&mut canned, Some("entry-1")); assert_eq!( result.message.as_deref(), Some( - "Error: Cannot branch while runtime work is active. Wait for the turn to finish, or cancel it first." + "Error: Cannot branch while runtime work is active:\n • shell_a3f2 running 5h 18m cw-leftovers.ps1\n\nWait for them to finish, or cancel them with /jobs cancel-all." ) ); assert_eq!(canned.transition_checks.get(), 1); @@ -278,17 +279,18 @@ fn new_composes_exact_baseline_outcomes() { // Blocked. let mut canned = CannedLifecycle { blocked: true, + blockers: vec!["shell_a3f2 running 5h 18m cw-leftovers.ps1".to_string()], ..CannedLifecycle::default() }; let result = super::new::new_portable(&mut canned, None); assert!(result.is_error); + let message = result.message.as_deref().unwrap_or_default(); assert!( - result - .message - .as_deref() - .unwrap_or_default() - .contains("only discards draft or queued input") + message.contains("Cannot start a new session while runtime work is active"), + "{message}" ); + assert!(message.contains("shell_a3f2"), "{message}"); + assert!(message.contains("/jobs cancel-all"), "{message}"); assert_eq!(canned.transition_checks.get(), 1); assert!(canned.fresh_forces.is_empty()); diff --git a/crates/tui/src/commands/groups/session/lifecycle_test_support.rs b/crates/tui/src/commands/groups/session/lifecycle_test_support.rs index 90a36af2d3..c5b6dd6cf6 100644 --- a/crates/tui/src/commands/groups/session/lifecycle_test_support.rs +++ b/crates/tui/src/commands/groups/session/lifecycle_test_support.rs @@ -16,6 +16,7 @@ use std::path::PathBuf; /// facet call. Unconfigured result slots return a descriptive canned error. pub(crate) struct CannedLifecycle { pub blocked: bool, + pub blockers: Vec, pub transition_checks: Cell, pub leaf_hint: Option, pub branch: Result, @@ -41,6 +42,7 @@ impl Default for CannedLifecycle { fn default() -> Self { Self { blocked: false, + blockers: Vec::new(), transition_checks: Cell::new(0), leaf_hint: None, branch: Err("canned: branch_to not configured".to_string()), @@ -81,6 +83,9 @@ impl CommandSessionLifecycleContext for CannedLifecycle { .set(self.transition_checks.get().saturating_add(1)); self.blocked } + fn transition_blockers(&self) -> Vec { + self.blockers.clone() + } fn branch_current_leaf_hint(&self) -> Option { self.leaf_hint.clone() } diff --git a/crates/tui/src/commands/groups/session/load.rs b/crates/tui/src/commands/groups/session/load.rs index 600ba7c94d..b8c7e172ac 100644 --- a/crates/tui/src/commands/groups/session/load.rs +++ b/crates/tui/src/commands/groups/session/load.rs @@ -1,6 +1,6 @@ //! `/load` command. -use super::CommandResult; +use super::{CommandResult, transition_blocked_message}; use codewhale_command_contract::facets::CommandSessionLifecycleContext; use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; @@ -56,10 +56,10 @@ pub(in crate::commands) fn load_portable( arg: Option<&str>, ) -> CommandResult { if lifecycle.transition_blocked() { - return CommandResult::error( - "Cannot load a session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work first." - .to_string(), - ); + return CommandResult::error(transition_blocked_message( + "load a session", + &lifecycle.transition_blockers(), + )); } let Some(path) = arg.map(str::trim).filter(|p| !p.is_empty()) else { return CommandResult::error("Usage: /load ".to_string()); diff --git a/crates/tui/src/commands/groups/session/mod.rs b/crates/tui/src/commands/groups/session/mod.rs index 6dc8ccc9de..1764c5cf3c 100644 --- a/crates/tui/src/commands/groups/session/mod.rs +++ b/crates/tui/src/commands/groups/session/mod.rs @@ -28,6 +28,23 @@ mod session; pub(in crate::commands) const MAX_TITLE_LEN: usize = 100; +/// The one way the lifecycle commands report a closed transition gate: the +/// verb, the named blockers, and the supported way out. +pub(in crate::commands) fn transition_blocked_message(verb: &str, blockers: &[String]) -> String { + let mut message = format!("Cannot {verb} while runtime work is active"); + if blockers.is_empty() { + message.push('.'); + } else { + message.push(':'); + for blocker in blockers { + message.push_str("\n • "); + message.push_str(blocker); + } + } + message.push_str("\n\nWait for them to finish, or cancel them with /jobs cancel-all."); + message +} + /// Promote the no-action structcopy result to the group's action vocabulary. /// The leaf itself retains its narrower, impossible-action return type. pub(in crate::commands) struct StructcopyRegistration; diff --git a/crates/tui/src/commands/groups/session/new.rs b/crates/tui/src/commands/groups/session/new.rs index 74bad96852..85b379ff5d 100644 --- a/crates/tui/src/commands/groups/session/new.rs +++ b/crates/tui/src/commands/groups/session/new.rs @@ -1,6 +1,6 @@ //! `/new` command — start a fresh saved session from the current TUI state. -use super::CommandResult; +use super::{CommandResult, transition_blocked_message}; use codewhale_command_contract::facets::CommandSessionLifecycleContext; use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; @@ -61,10 +61,10 @@ pub(in crate::commands) fn new_portable( } }; if lifecycle.transition_blocked() { - return CommandResult::error( - "Cannot start a new session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work. `/new --force` only discards draft or queued input." - .to_string(), - ); + return CommandResult::error(transition_blocked_message( + "start a new session", + &lifecycle.transition_blockers(), + )); } match lifecycle.fresh_session(force) { Ok(receipt) => CommandResult::with_message_and_action( diff --git a/crates/tui/src/commands/groups/session/resume.rs b/crates/tui/src/commands/groups/session/resume.rs index 19a4bcff33..f406e18d12 100644 --- a/crates/tui/src/commands/groups/session/resume.rs +++ b/crates/tui/src/commands/groups/session/resume.rs @@ -5,7 +5,7 @@ //! `resolve_resume_source` / `import_session_file` / `open_resume_picker` //! delegates (transition blocking is checked before any picker or I/O). -use super::CommandResult; +use super::{CommandResult, transition_blocked_message}; use codewhale_command_contract::facets::{CommandSessionControlContext, ResumeSource}; use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; use codewhale_command_contract::metadata::{ @@ -49,9 +49,10 @@ pub(in crate::commands) fn resume_portable( arg: Option<&str>, ) -> CommandResult { if control.transition_blocked() { - return CommandResult::error( - "Cannot resume while runtime work is active. Wait for the turn to finish, or cancel it first.", - ); + return CommandResult::error(transition_blocked_message( + "resume", + &control.transition_blockers(), + )); } let Some(raw) = arg.map(str::trim).filter(|s| !s.is_empty()) else { control.open_resume_picker(); @@ -107,16 +108,17 @@ mod tests { fn resume_transition_blocking_wins_before_any_route() { let mut fake = control_fake(); fake.blocked = true; + fake.blockers = vec!["a turn is still running".to_string()]; let result = resume_portable(&mut fake, Some("anything")); assert!(result.is_error); assert_eq!( message(&result), - "Cannot resume while runtime work is active. Wait for the turn to finish, or cancel it first." + "Cannot resume while runtime work is active:\n • a turn is still running\n\nWait for them to finish, or cancel them with /jobs cancel-all." ); assert_eq!( fake.calls.borrow().as_slice(), - ["transition_blocked"], - "the gate executes exactly once before route work" + ["transition_blocked", "transition_blockers"], + "the gate executes exactly once before route work, then names its blockers" ); } diff --git a/crates/tui/src/commands/session_lifecycle_regression_tests.rs b/crates/tui/src/commands/session_lifecycle_regression_tests.rs index 7b5e9e57dd..fc8526e045 100644 --- a/crates/tui/src/commands/session_lifecycle_regression_tests.rs +++ b/crates/tui/src/commands/session_lifecycle_regression_tests.rs @@ -471,11 +471,14 @@ fn new_session_force_cannot_detach_an_in_flight_turn() { assert!(result.action.is_none()); assert_eq!(app.current_session_id.as_deref(), Some("old-session")); assert_eq!(app.api_messages.len(), 1); + let message = result.message.as_deref().unwrap_or_default(); assert!( - result - .message - .as_deref() - .is_some_and(|message| message.contains("only discards draft or queued input")) + message.contains("Cannot start a new session while runtime work is active"), + "{message}" + ); + assert!( + message.contains("a turn is still running"), + "the refusal names the live turn: {message}" ); } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 05e765dd8c..5015b3bd30 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -549,6 +549,13 @@ impl AgentRecentAction { pub(crate) const MAX_AGENT_RECENT_ACTIONS: usize = 3; +/// Most blocked-transition task rows named before the rest are summarized; +/// an error message must not become a screenful. +const MAX_TRANSITION_BLOCKER_TASKS: usize = 5; + +/// Summary budget for one task row in a blocked-transition message. +const TRANSITION_BLOCKER_SUMMARY_CHARS: usize = 60; + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AgentProgressMeta { pub parent_run_id: Option, @@ -4012,21 +4019,97 @@ impl App { /// contaminate the replacement session after clear/load/new. #[must_use] pub fn session_transition_blocked(&self) -> bool { - // A dispatch still resolving its route, and a locally cancelled turn - // whose terminal event has not landed, both belong to this session: - // switching now would hand the next session a stale suppression that - // cancels its first turn, or a dispatch bound to the old one (U02-10). - self.is_loading - || self.dispatch_in_flight - || self.suppress_stream_events_until_turn_complete - || self.runtime_turn_status.as_deref() == Some("in_progress") - || self.is_compacting - || self.manual_compaction_queued - || self.is_purging - || self - .task_panel - .iter() - .any(|task| matches!(task.status.as_str(), "queued" | "running")) + !self.session_transition_blockers().is_empty() + } + + /// Why a session transition is blocked, in operator terms. Empty means the + /// transition is allowed; [`Self::session_transition_blocked`] derives from + /// this same list so the gate and its explanation cannot drift. + /// + /// A dispatch still resolving its route, and a locally cancelled turn + /// whose terminal event has not landed, both belong to this session: + /// switching now would hand the next session a stale suppression that + /// cancels its first turn, or a dispatch bound to the old one (U02-10). + #[must_use] + pub fn session_transition_blockers(&self) -> Vec { + let mut blockers = Vec::new(); + + let mut tasks: Vec<&TaskPanelEntry> = self + .task_panel + .iter() + .filter(|task| matches!(task.status.as_str(), "queued" | "running")) + .collect(); + let hidden = tasks.len().saturating_sub(MAX_TRANSITION_BLOCKER_TASKS); + tasks.truncate(MAX_TRANSITION_BLOCKER_TASKS); + blockers.extend(Self::transition_blocker_task_lines(&tasks)); + if hidden > 0 { + blockers.push(format!("…and {hidden} more")); + } + + if self.is_loading { + blockers.push("a turn is still running".to_string()); + } + if self.dispatch_in_flight { + blockers.push("an earlier message is still resolving its route".to_string()); + } + if self.suppress_stream_events_until_turn_complete { + blockers.push("a cancelled turn is still settling".to_string()); + } + if self.runtime_turn_status.as_deref() == Some("in_progress") { + blockers.push("the runtime reports a turn in progress".to_string()); + } + if self.is_compacting { + blockers.push("context compaction is running".to_string()); + } + if self.manual_compaction_queued { + blockers.push("a manual compaction is queued".to_string()); + } + if self.is_purging { + blockers.push("session cleanup is running".to_string()); + } + blockers + } + + /// One aligned row per blocking task. Every column but the summary is + /// ASCII, so padding by character count matches the rendered width. + fn transition_blocker_task_lines(tasks: &[&TaskPanelEntry]) -> Vec { + let rows: Vec<(String, String, String, String)> = tasks + .iter() + .map(|task| { + let duration = task + .duration_ms + .map(crate::agent_roster::format_duration) + .unwrap_or_default(); + let summary: String = task.prompt_summary.replace(['\n', '\r'], " "); + let summary = crate::rlm::turn::truncate_text( + summary.trim(), + TRANSITION_BLOCKER_SUMMARY_CHARS, + ); + (task.id.clone(), task.status.clone(), duration, summary) + }) + .collect(); + let id_width = rows + .iter() + .map(|row| row.0.chars().count()) + .max() + .unwrap_or(0); + let status_width = rows + .iter() + .map(|row| row.1.chars().count()) + .max() + .unwrap_or(0); + let duration_width = rows + .iter() + .map(|row| row.2.chars().count()) + .max() + .unwrap_or(0); + rows.iter() + .map(|(id, status, duration, summary)| { + format!( + "{id: Date: Fri, 9 Oct 2026 20:17:48 +0800 Subject: [PATCH 2/4] fix(tui): point the refusal at exits that actually clear it The shared tail named /jobs cancel-all for every blocker, but that command only kills shell processes: it maps to ShellJobAction::CancelAll -> kill_running_for_session and answers "No running commands to cancel." for a turn, dispatch, compaction, or cleanup blocker, so the message handed those users a way out that does nothing (adversarial review of this PR, finding P2). The tail now names both exits with their scope: Ctrl+C stops a running turn, and /jobs cancel-all cancels running shell jobs. "Wait for them to finish" stays as the advice that is always true. --- .../groups/session/lifecycle_portable_tests.rs | 2 +- crates/tui/src/commands/groups/session/mod.rs | 10 ++++++++-- crates/tui/src/commands/groups/session/resume.rs | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs b/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs index 3e9ff0693b..bf10b50c2c 100644 --- a/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs +++ b/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs @@ -59,7 +59,7 @@ fn branch_composes_exact_baseline_messages() { assert_eq!( result.message.as_deref(), Some( - "Error: Cannot branch while runtime work is active:\n • shell_a3f2 running 5h 18m cw-leftovers.ps1\n\nWait for them to finish, or cancel them with /jobs cancel-all." + "Error: Cannot branch while runtime work is active:\n • shell_a3f2 running 5h 18m cw-leftovers.ps1\n\nWait for them to finish, or cancel what is still cancelable: Ctrl+C stops a running turn, and /jobs cancel-all cancels running shell jobs." ) ); assert_eq!(canned.transition_checks.get(), 1); diff --git a/crates/tui/src/commands/groups/session/mod.rs b/crates/tui/src/commands/groups/session/mod.rs index 1764c5cf3c..cc8428a497 100644 --- a/crates/tui/src/commands/groups/session/mod.rs +++ b/crates/tui/src/commands/groups/session/mod.rs @@ -29,7 +29,7 @@ mod session; pub(in crate::commands) const MAX_TITLE_LEN: usize = 100; /// The one way the lifecycle commands report a closed transition gate: the -/// verb, the named blockers, and the supported way out. +/// verb, the named blockers, and the exits that actually clear them. pub(in crate::commands) fn transition_blocked_message(verb: &str, blockers: &[String]) -> String { let mut message = format!("Cannot {verb} while runtime work is active"); if blockers.is_empty() { @@ -41,7 +41,13 @@ pub(in crate::commands) fn transition_blocked_message(verb: &str, blockers: &[St message.push_str(blocker); } } - message.push_str("\n\nWait for them to finish, or cancel them with /jobs cancel-all."); + // `/jobs cancel-all` only kills shell processes and answers "No running + // commands to cancel." for everything else, so it must not read as the + // exit for a turn, dispatch, compaction, or cleanup blocker. + message.push_str( + "\n\nWait for them to finish, or cancel what is still cancelable: \ + Ctrl+C stops a running turn, and /jobs cancel-all cancels running shell jobs.", + ); message } diff --git a/crates/tui/src/commands/groups/session/resume.rs b/crates/tui/src/commands/groups/session/resume.rs index f406e18d12..e2f253ec69 100644 --- a/crates/tui/src/commands/groups/session/resume.rs +++ b/crates/tui/src/commands/groups/session/resume.rs @@ -113,7 +113,7 @@ mod tests { assert!(result.is_error); assert_eq!( message(&result), - "Cannot resume while runtime work is active:\n • a turn is still running\n\nWait for them to finish, or cancel them with /jobs cancel-all." + "Cannot resume while runtime work is active:\n • a turn is still running\n\nWait for them to finish, or cancel what is still cancelable: Ctrl+C stops a running turn, and /jobs cancel-all cancels running shell jobs." ); assert_eq!( fake.calls.borrow().as_slice(), From fd23382e27caffac8f9102c18d71277badd1b732 Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Fri, 9 Oct 2026 21:20:00 +0800 Subject: [PATCH 3/4] fix(tui): make the blocker rows fit a terminal and name what the user owns Adversarial review of this PR (SpikeBot 003) left seven findings. The two majors are the row budget and the test that could not fail: - A row was bounded by a 60-character summary, and the summary counted characters. Rows render into a Note body that is 74 columns wide in an 80-column terminal, so a CJK summary (60 ideographs = 120 columns) doubled the row and five of them wrapped the whole message. The budget is now derived from the assembled columns and truncation goes through truncate_line_to_width, the existing display-width-aware helper. - The "stays bounded" assertion allowed 100 characters, eighteen more than a row can occupy, and so passed even when the alignment it claimed to protect was broken. It now measures display width against the real 74-column body, and the summary is CJK in the test so a character-count budget cannot pass. The remaining findings, also addressed: - A missing duration renders `-`, the placeholder the task list already uses, instead of collapsing the column. - A row names its owning sub-agent (`(by verifier) ...`): an unclaimed background command is the case an operator most needs to identify. - The panel's `shell: ` prefix is stripped from the command. - `a turn is still running` becomes `the session is still busy with the current turn`: `is_loading` is set by paths with no turn behind them. - `/new` states the `--force` scope again. The shared tail named the exits that clear a blocker, and `--force` is not one of them, so the one place that owns the flag has to say what it does. - `/clear` shows the same blocker list: it consults the same gate, and a defect fixed in five of six commands is not fixed. - Both facets document that the projection is display-only and must not be parsed back as data. --- crates/command-contract/src/facets.rs | 10 ++ crates/tui/src/commands/groups/core/core.rs | 13 +- .../session/lifecycle_portable_tests.rs | 6 +- crates/tui/src/commands/groups/session/mod.rs | 5 +- crates/tui/src/commands/groups/session/new.rs | 11 +- .../tui/src/commands/groups/session/resume.rs | 4 +- .../session_lifecycle_regression_tests.rs | 2 +- crates/tui/src/tui/app.rs | 49 +++++-- crates/tui/src/tui/ui/tests.rs | 128 +++++++++++++++--- 9 files changed, 188 insertions(+), 40 deletions(-) diff --git a/crates/command-contract/src/facets.rs b/crates/command-contract/src/facets.rs index cf7a6d3fd0..e7fcac5576 100644 --- a/crates/command-contract/src/facets.rs +++ b/crates/command-contract/src/facets.rs @@ -1385,6 +1385,11 @@ pub trait CommandSessionLifecycleContext { /// transition is allowed. Consulted only after [`Self::transition_blocked`] /// reports a block, so the refusal can name the work that holds the /// session instead of restating that it is held. + /// + /// Display projection, not a data source: the strings are pre-rendered + /// for a refusal message and carry no stable shape. A caller that needs + /// ids, durations, or owners should add a structured method instead of + /// parsing these back out. fn transition_blockers(&self) -> Vec; /// `/branch` with no argument: the current leaf when an active journaled @@ -1645,6 +1650,11 @@ pub trait CommandSessionControlContext { /// Why the transition gate is closed, in operator terms; empty means the /// transition is allowed. Consulted only after [`Self::transition_blocked`] /// reports a block. + /// + /// Display projection, not a data source: the strings are pre-rendered + /// for a refusal message and carry no stable shape. A caller that needs + /// ids, durations, or owners should add a structured method instead of + /// parsing these back out. fn transition_blockers(&self) -> Vec; /// `/relay`: authoritative semantic snapshot (workspace/mode/model/goal/ diff --git a/crates/tui/src/commands/groups/core/core.rs b/crates/tui/src/commands/groups/core/core.rs index b7e4f37726..86d0f9bac4 100644 --- a/crates/tui/src/commands/groups/core/core.rs +++ b/crates/tui/src/commands/groups/core/core.rs @@ -150,9 +150,16 @@ fn user_command_help( /// Clear conversation history pub fn clear(app: &mut App) -> CommandResult { if app.session_transition_blocked() { - return CommandResult::error( - tr(app.ui_locale, MessageId::ClearConversationBusy).to_string(), - ); + // The same gate the lifecycle commands consult, so the refusal owes + // the same explanation. The sentence stays localized; a blocker row + // is data (id, status, elapsed, summary) and is not translated + // anywhere else either. + let mut message = tr(app.ui_locale, MessageId::ClearConversationBusy).to_string(); + for blocker in app.session_transition_blockers() { + message.push_str("\n • "); + message.push_str(&blocker); + } + return CommandResult::error(message); } let new_id = uuid::Uuid::new_v4().to_string(); let queue_transition = match crate::tui::ui::prepare_offline_queue_transition(app, &new_id) { diff --git a/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs b/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs index bf10b50c2c..b3fb39aa1f 100644 --- a/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs +++ b/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs @@ -59,7 +59,7 @@ fn branch_composes_exact_baseline_messages() { assert_eq!( result.message.as_deref(), Some( - "Error: Cannot branch while runtime work is active:\n • shell_a3f2 running 5h 18m cw-leftovers.ps1\n\nWait for them to finish, or cancel what is still cancelable: Ctrl+C stops a running turn, and /jobs cancel-all cancels running shell jobs." + "Error: Cannot branch while runtime work is active:\n • shell_a3f2 running 5h 18m cw-leftovers.ps1\n\nWait for the work to finish, or stop what you can: Ctrl+C stops a running turn, and /jobs cancel-all cancels running shell jobs." ) ); assert_eq!(canned.transition_checks.get(), 1); @@ -291,6 +291,10 @@ fn new_composes_exact_baseline_outcomes() { ); assert!(message.contains("shell_a3f2"), "{message}"); assert!(message.contains("/jobs cancel-all"), "{message}"); + assert!( + message.contains("`/new --force` only discards draft or queued input"), + "the flag's real scope stays stated where the flag exists: {message}" + ); assert_eq!(canned.transition_checks.get(), 1); assert!(canned.fresh_forces.is_empty()); diff --git a/crates/tui/src/commands/groups/session/mod.rs b/crates/tui/src/commands/groups/session/mod.rs index cc8428a497..55ffd15ef3 100644 --- a/crates/tui/src/commands/groups/session/mod.rs +++ b/crates/tui/src/commands/groups/session/mod.rs @@ -43,9 +43,10 @@ pub(in crate::commands) fn transition_blocked_message(verb: &str, blockers: &[St } // `/jobs cancel-all` only kills shell processes and answers "No running // commands to cancel." for everything else, so it must not read as the - // exit for a turn, dispatch, compaction, or cleanup blocker. + // exit for a turn, dispatch, compaction, or cleanup blocker. Name both + // exits with the scope each one actually clears. message.push_str( - "\n\nWait for them to finish, or cancel what is still cancelable: \ + "\n\nWait for the work to finish, or stop what you can: \ Ctrl+C stops a running turn, and /jobs cancel-all cancels running shell jobs.", ); message diff --git a/crates/tui/src/commands/groups/session/new.rs b/crates/tui/src/commands/groups/session/new.rs index 85b379ff5d..fc46f1193c 100644 --- a/crates/tui/src/commands/groups/session/new.rs +++ b/crates/tui/src/commands/groups/session/new.rs @@ -61,10 +61,13 @@ pub(in crate::commands) fn new_portable( } }; if lifecycle.transition_blocked() { - return CommandResult::error(transition_blocked_message( - "start a new session", - &lifecycle.transition_blockers(), - )); + // The shared tail names the exits that clear a blocker; `--force` is + // not one of them, and a user who has just read it will try exactly + // that. Say what it does here, where the flag exists. + let mut message = + transition_blocked_message("start a new session", &lifecycle.transition_blockers()); + message.push_str("\n\n`/new --force` only discards draft or queued input."); + return CommandResult::error(message); } match lifecycle.fresh_session(force) { Ok(receipt) => CommandResult::with_message_and_action( diff --git a/crates/tui/src/commands/groups/session/resume.rs b/crates/tui/src/commands/groups/session/resume.rs index e2f253ec69..aa1b79633c 100644 --- a/crates/tui/src/commands/groups/session/resume.rs +++ b/crates/tui/src/commands/groups/session/resume.rs @@ -108,12 +108,12 @@ mod tests { fn resume_transition_blocking_wins_before_any_route() { let mut fake = control_fake(); fake.blocked = true; - fake.blockers = vec!["a turn is still running".to_string()]; + fake.blockers = vec!["the session is still busy with the current turn".to_string()]; let result = resume_portable(&mut fake, Some("anything")); assert!(result.is_error); assert_eq!( message(&result), - "Cannot resume while runtime work is active:\n • a turn is still running\n\nWait for them to finish, or cancel what is still cancelable: Ctrl+C stops a running turn, and /jobs cancel-all cancels running shell jobs." + "Cannot resume while runtime work is active:\n • the session is still busy with the current turn\n\nWait for the work to finish, or stop what you can: Ctrl+C stops a running turn, and /jobs cancel-all cancels running shell jobs." ); assert_eq!( fake.calls.borrow().as_slice(), diff --git a/crates/tui/src/commands/session_lifecycle_regression_tests.rs b/crates/tui/src/commands/session_lifecycle_regression_tests.rs index fc8526e045..5971c3760f 100644 --- a/crates/tui/src/commands/session_lifecycle_regression_tests.rs +++ b/crates/tui/src/commands/session_lifecycle_regression_tests.rs @@ -477,7 +477,7 @@ fn new_session_force_cannot_detach_an_in_flight_turn() { "{message}" ); assert!( - message.contains("a turn is still running"), + message.contains("the session is still busy with the current turn"), "the refusal names the live turn: {message}" ); } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 5015b3bd30..50d7c3983d 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -553,8 +553,15 @@ pub(crate) const MAX_AGENT_RECENT_ACTIONS: usize = 3; /// an error message must not become a screenful. const MAX_TRANSITION_BLOCKER_TASKS: usize = 5; -/// Summary budget for one task row in a blocked-transition message. -const TRANSITION_BLOCKER_SUMMARY_CHARS: usize = 60; +/// Display columns one blocked-transition task row may occupy. The row is +/// rendered as a transcript Note, whose content width in an 80-column +/// terminal is 74; a wider row wraps into the message and strands its own +/// continuation lines. +const TRANSITION_BLOCKER_LINE_WIDTH: usize = 74; + +/// Floor for the summary budget when the other columns are unusually wide; +/// below this a row names an id and nothing useful about it. +const MIN_TRANSITION_BLOCKER_SUMMARY_WIDTH: usize = 16; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AgentProgressMeta { @@ -4047,7 +4054,7 @@ impl App { } if self.is_loading { - blockers.push("a turn is still running".to_string()); + blockers.push("the session is still busy with the current turn".to_string()); } if self.dispatch_in_flight { blockers.push("an earlier message is still resolving its route".to_string()); @@ -4070,21 +4077,35 @@ impl App { blockers } - /// One aligned row per blocking task. Every column but the summary is - /// ASCII, so padding by character count matches the rendered width. + /// One aligned row per blocking task, bounded to the transcript's content + /// width. Every column but the summary is ASCII, so padding by character + /// count matches the rendered width there; the summary is truncated by + /// display width so CJK text cannot silently double the row. fn transition_blocker_task_lines(tasks: &[&TaskPanelEntry]) -> Vec { let rows: Vec<(String, String, String, String)> = tasks .iter() .map(|task| { + // `-` keeps the column present for rows without a duration, + // the placeholder `format_task_list` already uses. let duration = task .duration_ms .map(crate::agent_roster::format_duration) - .unwrap_or_default(); - let summary: String = task.prompt_summary.replace(['\n', '\r'], " "); - let summary = crate::rlm::turn::truncate_text( - summary.trim(), - TRANSITION_BLOCKER_SUMMARY_CHARS, - ); + .unwrap_or_else(|| "-".to_string()); + // Panel summaries carry the `shell: ` prefix the Work rows + // strip; a blocker row names the command, not its source kind. + let command = task + .prompt_summary + .strip_prefix("shell: ") + .unwrap_or(task.prompt_summary.as_str()) + .replace(['\n', '\r'], " "); + // An owned shell names its owner: an unclaimed background + // command is the case an operator most needs to identify. + let summary = match task.owner_agent_name.as_deref() { + Some(owner) if !owner.trim().is_empty() => { + format!("(by {}) {}", owner.trim(), command.trim()) + } + _ => command.trim().to_string(), + }; (task.id.clone(), task.status.clone(), duration, summary) }) .collect(); @@ -4103,8 +4124,14 @@ impl App { .map(|row| row.2.chars().count()) .max() .unwrap_or(0); + let fixed_width = id_width + 2 + status_width + 2 + duration_width + 2; + let summary_width = TRANSITION_BLOCKER_LINE_WIDTH + .saturating_sub(fixed_width) + .max(MIN_TRANSITION_BLOCKER_SUMMARY_WIDTH); rows.iter() .map(|(id, status, duration, summary)| { + let summary = + crate::tui::ui_text::truncate_line_to_width(summary, summary_width); format!( "{id: Date: Fri, 9 Oct 2026 22:51:15 +0800 Subject: [PATCH 4/4] style(tui): match rustfmt 1.99 on the row-width assertion CI's rustfmt (stable 1.99.0) keeps the four-element chain on one line; the local 1.98.1 split it, so `cargo fmt --all -- --check` failed on the pushed head while passing locally. The check follows the same rule everywhere else in the file, so this is the whole difference. Not run locally: the divergence is between rustfmt versions, so re-formatting here would only reproduce the 1.98 shape. CI's formatter is the authority. --- crates/tui/src/tui/ui/tests.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 56681c6f5c..606951d199 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -20773,9 +20773,7 @@ fn transition_blockers_summarize_past_the_fifth_task_and_bound_each_summary() { ); assert_eq!(blockers[5], "…and 1 more"); assert!( - blockers[..5] - .iter() - .all(|row| row.as_str().width() <= 74), + blockers[..5].iter().all(|row| row.as_str().width() <= 74), "rows must fit the 74-column Note body: {blockers:?}" ); assert!(