| 1 | //! Paused-command planning and dispatch preparation types |
| 2 | //! (TUI_MODULARIZATION.md slice 6). Actual dispatch execution stays in |
| 3 | //! `dispatch.rs`; this module owns the pause/resume plan and the |
| 4 | //! preparation/outcome types shared with it. |
| 5 | |
| 6 | use super::*; |
| 7 | |
| 8 | pub(crate) const INITIAL_PROMPT_DEFERRED_STATUS: &str = |
| 9 | "Initial prompt ready; complete setup to send it"; |
| 10 | |
| 11 | pub(crate) fn paused_goal_objective_title(objective: &str) -> &str { |
| 12 | objective |
| 13 | .split(['\n', '\r']) |
| 14 | .next() |
| 15 | .map(str::trim) |
| 16 | .filter(|line| !line.is_empty()) |
| 17 | .unwrap_or("the paused command") |
| 18 | } |
| 19 | |
| 20 | pub(crate) fn is_resume_message(message: &str) -> bool { |
| 21 | let words: Vec<String> = message |
| 22 | .to_ascii_lowercase() |
| 23 | .split(|ch: char| !ch.is_ascii_alphanumeric()) |
| 24 | .filter(|word| !word.is_empty()) |
| 25 | .map(str::to_string) |
| 26 | .collect(); |
| 27 | if words.is_empty() { |
| 28 | return false; |
| 29 | } |
| 30 | let text = words.join(" "); |
| 31 | let has_resume_verb = words |
| 32 | .iter() |
| 33 | .any(|word| matches!(word.as_str(), "continue" | "resume")); |
| 34 | if !has_resume_verb { |
| 35 | return false; |
| 36 | } |
| 37 | |
| 38 | let blockers = [ |
| 39 | "do not continue", |
| 40 | "do not resume", |
| 41 | "don t continue", |
| 42 | "don t resume", |
| 43 | "dont continue", |
| 44 | "dont resume", |
| 45 | "not continue", |
| 46 | "not resume", |
| 47 | "continue yet", |
| 48 | "resume yet", |
| 49 | "will continue", |
| 50 | "will resume", |
| 51 | "continue tomorrow", |
| 52 | "resume tomorrow", |
| 53 | "continue later", |
| 54 | "resume later", |
| 55 | ]; |
| 56 | if blockers.iter().any(|blocker| text.contains(blocker)) { |
| 57 | return false; |
| 58 | } |
| 59 | if matches!( |
| 60 | words.first().map(String::as_str), |
| 61 | Some("how" | "what" | "when" | "where" | "why") |
| 62 | ) { |
| 63 | return false; |
| 64 | } |
| 65 | |
| 66 | if words.len() == 1 { |
| 67 | return true; |
| 68 | } |
| 69 | |
| 70 | let context_words = [ |
| 71 | "please", "now", "paused", "pause", "command", "task", "work", "request", "goal", |
| 72 | "previous", "last", "same", "it", "that", "this", "go", "ahead", |
| 73 | ]; |
| 74 | if words |
| 75 | .iter() |
| 76 | .any(|word| context_words.contains(&word.as_str())) |
| 77 | { |
| 78 | return true; |
| 79 | } |
| 80 | |
| 81 | text.starts_with("can you continue") |
| 82 | || text.starts_with("can you resume") |
| 83 | || text.starts_with("could you continue") |
| 84 | || text.starts_with("could you resume") |
| 85 | } |
| 86 | |
| 87 | pub(crate) fn paused_command_note(title: &str, resume: bool) -> String { |
| 88 | let instruction = if resume { |
| 89 | "The user is resuming that paused command. Continue the paused command." |
| 90 | } else { |
| 91 | "The user is not resuming that paused command. Answer only the new message and do not continue the paused command." |
| 92 | }; |
| 93 | format!( |
| 94 | "\n\nCodewhale paused custom slash command context:\n\ |
| 95 | Paused custom slash command: {title}\n\ |
| 96 | Paused command: {title}\n\ |
| 97 | {instruction}" |
| 98 | ) |
| 99 | } |
| 100 | |
| 101 | #[derive(Debug, Clone)] |
| 102 | pub(crate) enum PausedCommandDispatch { |
| 103 | None, |
| 104 | ClearWithoutQuarry, |
| 105 | Resume { objective: String, note: String }, |
| 106 | Detach { note: String }, |
| 107 | } |
| 108 | |
| 109 | impl PausedCommandDispatch { |
| 110 | pub(super) fn note(&self) -> Option<&str> { |
| 111 | match self { |
| 112 | Self::Resume { note, .. } | Self::Detach { note } => Some(note), |
| 113 | Self::None | Self::ClearWithoutQuarry => None, |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | pub(super) fn goal_objective(&self, app: &App) -> Option<String> { |
| 118 | match self { |
| 119 | Self::Resume { objective, .. } => Some(objective.clone()), |
| 120 | Self::Detach { .. } | Self::ClearWithoutQuarry => None, |
| 121 | Self::None => app.goal.objective.clone(), |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | pub(super) fn apply(self, app: &mut App, engine_handle: &EngineHandle) { |
| 126 | engine_handle.set_paused(false); |
| 127 | match self { |
| 128 | Self::None => {} |
| 129 | Self::ClearWithoutQuarry => { |
| 130 | app.paused = false; |
| 131 | app.pausable = false; |
| 132 | } |
| 133 | Self::Resume { objective, .. } => { |
| 134 | app.paused = false; |
| 135 | app.paused_goal_objective = None; |
| 136 | app.goal.objective = Some(objective); |
| 137 | app.pausable = true; |
| 138 | } |
| 139 | Self::Detach { .. } => { |
| 140 | app.paused = false; |
| 141 | app.goal.objective = None; |
| 142 | app.goal.tokens_used = 0; |
| 143 | app.goal.time_used_seconds = 0; |
| 144 | app.goal.continuation_count = 0; |
| 145 | } |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | pub(crate) fn plan_paused_command_message(app: &App, user_message: &str) -> PausedCommandDispatch { |
| 151 | if !app.paused && app.paused_goal_objective.is_none() { |
| 152 | return PausedCommandDispatch::None; |
| 153 | } |
| 154 | |
| 155 | let Some(objective) = app |
| 156 | .paused_goal_objective |
| 157 | .clone() |
| 158 | .or_else(|| app.goal.objective.clone()) |
| 159 | else { |
| 160 | return PausedCommandDispatch::ClearWithoutQuarry; |
| 161 | }; |
| 162 | let title = paused_goal_objective_title(&objective).to_string(); |
| 163 | if is_resume_message(user_message) { |
| 164 | PausedCommandDispatch::Resume { |
| 165 | objective, |
| 166 | note: paused_command_note(&title, true), |
| 167 | } |
| 168 | } else { |
| 169 | PausedCommandDispatch::Detach { |
| 170 | note: paused_command_note(&title, false), |
| 171 | } |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | pub(crate) fn pause_pausable_command(app: &mut App, engine_handle: &EngineHandle) { |
| 176 | app.paused_goal_objective = app |
| 177 | .paused_goal_objective |
| 178 | .clone() |
| 179 | .or_else(|| app.goal.objective.clone()); |
| 180 | app.goal.objective = None; |
| 181 | app.goal.tokens_used = 0; |
| 182 | app.goal.time_used_seconds = 0; |
| 183 | app.goal.continuation_count = 0; |
| 184 | app.paused = true; |
| 185 | app.pausable = true; |
| 186 | engine_handle.set_paused(true); |
| 187 | app.status_message = Some( |
| 188 | "Request paused. Send `continue` or `resume` to continue, or Esc to cancel.".to_string(), |
| 189 | ); |
| 190 | } |
| 191 | |
| 192 | pub(crate) fn clear_paused_command_state(app: &mut App, engine_handle: &EngineHandle) { |
| 193 | app.pausable = false; |
| 194 | app.paused = false; |
| 195 | app.paused_goal_objective = None; |
| 196 | engine_handle.set_paused(false); |
| 197 | } |
| 198 | |
| 199 | pub(crate) fn app_scoped_runtime_config(app: &App, config: &Config) -> (ProviderIdentity, Config) { |
| 200 | let identity = config |
| 201 | .resolve_persisted_provider_identity( |
| 202 | Some(app.api_provider.as_str()), |
| 203 | app.provider_id_for_persistence(), |
| 204 | ) |
| 205 | .unwrap_or_else(|_| ProviderIdentity { |
| 206 | provider: app.api_provider, |
| 207 | key: app.provider_identity_for_persistence().to_string(), |
| 208 | exact_id: app.provider_id_for_persistence().map(str::to_string), |
| 209 | migrated_legacy_ollama_cloud_route: false, |
| 210 | }); |
| 211 | let mut scoped = config.clone(); |
| 212 | scoped.scope_to_provider_identity(&identity); |
| 213 | (identity, scoped) |
| 214 | } |
| 215 | |
| 216 | #[derive(Debug, Clone, Copy)] |
| 217 | pub(crate) enum DispatchRecovery { |
| 218 | /// Normal immediate composer submit: restore the composer on failure. |
| 219 | Immediate, |
| 220 | /// A queued follow-up that was being edited in the composer. |
| 221 | Draft, |
| 222 | /// A queued follow-up pulled from the queue; re-insert at the prior index. |
| 223 | Queued { restore_index: Option<usize> }, |
| 224 | /// Initial `--prompt` / startup input. |
| 225 | Initial, |
| 226 | } |
| 227 | |
| 228 | /// Snapshot of App state taken before the sync prepare phase so a failed |
| 229 | /// dispatch can roll back the optimistic history/api_messages changes. |
| 230 | #[derive(Debug, Clone)] |
| 231 | pub(crate) struct UserDispatchSnapshot { |
| 232 | pub(crate) is_loading: bool, |
| 233 | pub(crate) suppress_stream_events_until_turn_complete: bool, |
| 234 | pub(crate) runtime_turn_status: Option<String>, |
| 235 | pub(crate) receipt_text: Option<String>, |
| 236 | pub(crate) receipt_started_at: Option<Instant>, |
| 237 | pub(crate) tool_evidence: Vec<ToolEvidence>, |
| 238 | pub(crate) history_len: usize, |
| 239 | pub(crate) history_revisions_len: usize, |
| 240 | pub(crate) history_version: u64, |
| 241 | pub(crate) api_messages_len: usize, |
| 242 | pub(crate) last_send_at: Option<Instant>, |
| 243 | } |
| 244 | |
| 245 | /// Data captured synchronously before the async dispatch phase. All values are |
| 246 | /// Send so the spawned task can resolve routes and send without holding `&mut App`. |
| 247 | #[allow(clippy::struct_excessive_bools)] |
| 248 | #[derive(Debug, Clone)] |
| 249 | pub(crate) struct UserDispatchPrepare { |
| 250 | pub(super) message: QueuedMessage, |
| 251 | pub(super) content: String, |
| 252 | pub(super) references: Vec<ContextReference>, |
| 253 | pub(super) paused_dispatch: PausedCommandDispatch, |
| 254 | pub(super) app_route_identity: ProviderIdentity, |
| 255 | pub(super) route_config: Config, |
| 256 | pub(super) goal_objective: Option<String>, |
| 257 | pub(super) goal_status: GoalStatus, |
| 258 | pub(super) goal_token_budget: Option<u32>, |
| 259 | pub(super) mode: AppMode, |
| 260 | pub(super) api_provider: ApiProvider, |
| 261 | pub(super) app_model: String, |
| 262 | pub(super) auto_model: bool, |
| 263 | pub(super) reasoning_effort: ReasoningEffort, |
| 264 | pub(super) allow_shell: bool, |
| 265 | pub(super) trust_mode: bool, |
| 266 | pub(super) auto_approve: bool, |
| 267 | pub(super) approval_mode: ApprovalMode, |
| 268 | pub(super) translation_enabled: bool, |
| 269 | pub(super) allowed_tools: Option<Vec<String>>, |
| 270 | pub(super) hook_executor: Option<Arc<HookExecutor>>, |
| 271 | pub(super) verbosity: Option<String>, |
| 272 | pub(super) provenance: UserInputProvenance, |
| 273 | pub(super) auto_router_context: String, |
| 274 | pub(super) should_auto_resolve: bool, |
| 275 | pub(super) auto_compact_user_configured: bool, |
| 276 | pub(super) auto_compact: bool, |
| 277 | pub(super) auto_compact_threshold_percent: f64, |
| 278 | pub(super) snapshot: UserDispatchSnapshot, |
| 279 | pub(super) cost_scope: crate::cost_status::CostScopeToken, |
| 280 | pub(super) message_index: usize, |
| 281 | pub(super) history_cell: usize, |
| 282 | } |
| 283 | |
| 284 | pub(crate) fn goal_status_from_snapshot(snapshot: &GoalSnapshot) -> Option<GoalStatus> { |
| 285 | match snapshot.status.trim() { |
| 286 | "active" => Some(GoalStatus::Active), |
| 287 | "paused" => Some(GoalStatus::Paused), |
| 288 | "complete" => Some(GoalStatus::Complete), |
| 289 | "blocked" => Some(GoalStatus::Blocked), |
| 290 | _ => None, |
| 291 | } |
| 292 | } |
| 293 |