Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions crates/command-contract/src/facets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>;

/// `/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).
Expand Down Expand Up @@ -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<String>;

/// `/relay`: authoritative semantic snapshot (workspace/mode/model/goal/
/// to-do/plan/compact-template). Unavailable sources are represented as
/// explicit states, never panics.
Expand Down
6 changes: 6 additions & 0 deletions crates/command-contract/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2235,6 +2235,9 @@ impl CommandSessionLifecycleContext for FakeLifecycle {
fn transition_blocked(&self) -> bool {
self.blocked
}
fn transition_blockers(&self) -> Vec<String> {
Vec::new()
}
fn branch_current_leaf_hint(&self) -> Option<String> {
self.leaf_hint.clone()
}
Expand Down Expand Up @@ -2558,6 +2561,9 @@ impl CommandSessionControlContext for FakeControl {
fn transition_blocked(&self) -> bool {
self.blocked
}
fn transition_blockers(&self) -> Vec<String> {
Vec::new()
}
fn relay_projection(&self) -> RelayProjection {
self.relay
.clone()
Expand Down
8 changes: 8 additions & 0 deletions crates/tui/src/commands/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,10 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
self.host.app.borrow().session_transition_blocked()
}

fn transition_blockers(&self) -> Vec<String> {
self.host.app.borrow().session_transition_blockers()
}

fn branch_current_leaf_hint(&self) -> Option<String> {
let app = self.host.app.borrow();
let session_id = app.current_session_id.as_deref()?;
Expand Down Expand Up @@ -1052,6 +1056,10 @@ impl CommandSessionControlContext for SessionControlAdapter<'_> {
self.host.app.borrow().session_transition_blocked()
}

fn transition_blockers(&self) -> Vec<String> {
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() {
Expand Down
13 changes: 10 additions & 3 deletions crates/tui/src/commands/groups/core/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
10 changes: 5 additions & 5 deletions crates/tui/src/commands/groups/session/branch.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub(crate) struct FakeControl {
pub(crate) start_info: Option<RemoteStartInfo>,
pub(crate) stop_refusal: Option<Option<String>>,
pub(crate) hosted: Option<Option<HostedWorkTarget>>,
pub(crate) blockers: Vec<String>,
pub(crate) calls: RefCell<Vec<String>>,
}

Expand All @@ -55,6 +56,10 @@ impl CommandSessionControlContext for FakeControl {
self.call("transition_blocked", None);
self.blocked
}
fn transition_blockers(&self) -> Vec<String> {
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()")
Expand Down
18 changes: 9 additions & 9 deletions crates/tui/src/commands/groups/session/fork.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
18 changes: 12 additions & 6 deletions crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub transition_checks: Cell<usize>,
pub leaf_hint: Option<String>,
pub branch: Result<SessionBranchOutcome, String>,
Expand All @@ -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()),
Expand Down Expand Up @@ -81,6 +83,9 @@ impl CommandSessionLifecycleContext for CannedLifecycle {
.set(self.transition_checks.get().saturating_add(1));
self.blocked
}
fn transition_blockers(&self) -> Vec<String> {
self.blockers.clone()
}
fn branch_current_leaf_hint(&self) -> Option<String> {
self.leaf_hint.clone()
}
Expand Down
10 changes: 5 additions & 5 deletions crates/tui/src/commands/groups/session/load.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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 <path>".to_string());
Expand Down
24 changes: 24 additions & 0 deletions crates/tui/src/commands/groups/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 8 additions & 5 deletions crates/tui/src/commands/groups/session/new.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 9 additions & 7 deletions crates/tui/src/commands/groups/session/resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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"
);
}

Expand Down
11 changes: 7 additions & 4 deletions crates/tui/src/commands/session_lifecycle_regression_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}

Expand Down
Loading
Loading