返回 CodeWhale
dispatch_prepare.rs
根目录 / crates / tui / src / tui / ui / dispatch_prepare.rs
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
293 lines RUST