diff --git a/crates/command-contract/src/facets.rs b/crates/command-contract/src/facets.rs index cafe02d78f..e7fcac5576 100644 --- a/crates/command-contract/src/facets.rs +++ b/crates/command-contract/src/facets.rs @@ -1381,6 +1381,17 @@ 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. + /// + /// 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 /// session resolves, otherwise `None` (the baseline silently falls back /// to the usage message on this path). @@ -1636,6 +1647,16 @@ 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. + /// + /// 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/ /// 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/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/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..b3fb39aa1f 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 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); @@ -278,16 +279,21 @@ 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!( + 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/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..55ffd15ef3 100644 --- a/crates/tui/src/commands/groups/session/mod.rs +++ b/crates/tui/src/commands/groups/session/mod.rs @@ -28,6 +28,30 @@ 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 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() { + message.push('.'); + } else { + message.push(':'); + for blocker in blockers { + message.push_str("\n • "); + message.push_str(blocker); + } + } + // `/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. Name both + // exits with the scope each one actually clears. + message.push_str( + "\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 +} + /// 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..fc46f1193c 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,13 @@ 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(), - ); + // 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 19a4bcff33..aa1b79633c 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!["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. Wait for the turn to finish, or cancel it first." + "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(), - ["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..5971c3760f 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("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 05e765dd8c..50d7c3983d 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -549,6 +549,20 @@ 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; + +/// 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 { pub parent_run_id: Option, @@ -4012,21 +4026,117 @@ 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("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()); + } + 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, 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_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(); + 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); + 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: