返回 CodeWhale
session_state.rs
根目录 / crates / tui / src / tui / ui / session_state.rs
1 //! Session durability: snapshot/restore, recovery after a crash or stall,
2 //! and workspace/worktree switching.
3 //!
4 //! Moved verbatim out of `ui.rs`.
5
6 use super::*;
7
8 pub(crate) async fn publish_pending_work_projection(app: &mut App) -> Result<bool, String> {
9 let Some(work) = app.runtime_services.work.clone() else {
10 return Ok(false);
11 };
12 let published = work.publish_pending().await?;
13 if published {
14 app.cached_work_summary = None;
15 }
16 Ok(published)
17 }
18
19 pub(crate) async fn persist_pending_work_checkpoint(app: &mut App) -> Result<bool, String> {
20 let Some(work) = app.runtime_services.work.clone() else {
21 return Ok(false);
22 };
23 if !work.has_pending_publish() {
24 return Ok(false);
25 }
26 let manager = SessionManager::default_location()
27 .map_err(|err| format!("could not open sessions directory: {err}"))?;
28 let session = build_session_snapshot(app, &manager)?;
29 if app.current_session_id.is_none() {
30 app.current_session_id = Some(session.metadata.id.clone());
31 }
32 if !persistence_actor::try_persist(PersistRequest::SaveCheckpoint { session }) {
33 return Err("persistence actor is unavailable".to_string());
34 }
35 publish_pending_work_projection(app).await
36 }
37
38 pub(crate) fn persist_with_pending_work_boundary(
39 app: &mut App,
40 request: PersistRequest,
41 ) -> Result<(), String> {
42 let has_pending = app
43 .runtime_services
44 .work
45 .as_ref()
46 .is_some_and(|work| work.has_pending_publish());
47 if !has_pending {
48 persistence_actor::persist(request);
49 return Ok(());
50 }
51 if !persistence_actor::try_persist(request) {
52 return Err("persistence actor is unavailable".to_string());
53 }
54 app.publish_pending_work_state().map(|_| ())
55 }
56
57 pub(crate) fn restore_matching_offline_queue_state(
58 app: &mut App,
59 state: OfflineQueueState,
60 ) -> bool {
61 if state.session_id.as_deref() != app.current_session_id.as_deref()
62 || state.session_id.is_none()
63 {
64 return false;
65 }
66 app.queued_messages = state
67 .messages
68 .into_iter()
69 .map(queued_session_to_ui)
70 .collect();
71 if let Some(draft) = state.draft.map(queued_session_to_ui) {
72 app.input.clone_from(&draft.display);
73 app.cursor_position = app.input.chars().count();
74 app.active_skill.clone_from(&draft.skill_instruction);
75 app.active_skill_provenance
76 .clone_from(&draft.skill_provenance);
77 app.queued_draft = Some(draft);
78 } else {
79 app.queued_draft = None;
80 }
81 app.needs_redraw = true;
82 true
83 }
84
85 pub(crate) fn reconcile_turn_liveness(
86 app: &mut App,
87 now: Instant,
88 has_running_agents: bool,
89 ) -> bool {
90 if app.is_loading
91 && app.runtime_turn_status.is_none()
92 && !has_running_agents
93 && !app.is_compacting
94 && !app.is_purging
95 && app.dispatch_started_at.is_some_and(|started| {
96 now.saturating_duration_since(started) > DISPATCH_WATCHDOG_TIMEOUT
97 })
98 {
99 // #2739: the user's prompt was already appended to api_messages
100 // before dispatch, but the turn never reached `in_progress`. Persist
101 // it before clearing turn state so `--continue` keeps the prompt
102 // instead of loading the previous save.
103 persist_recovery_snapshot(app);
104 app.is_loading = false;
105 app.dispatch_started_at = None;
106 app.turn_started_at = None;
107 app.turn_last_activity_at = None;
108 app.pending_turn_route = None;
109 app.pending_auto_route_receipt = None;
110 app.active_turn = None;
111 app.suppress_stream_events_until_turn_complete = false;
112 app.push_status_toast(
113 "Turn dispatch timed out; the engine may have stopped. Please try again.",
114 StatusToastLevel::Error,
115 None,
116 );
117 return true;
118 }
119
120 if app.is_loading
121 && matches!(
122 app.runtime_turn_status.as_deref(),
123 Some("completed" | "interrupted" | "failed")
124 )
125 && !has_running_agents
126 && !app.is_compacting
127 && !app.is_purging
128 {
129 app.is_loading = false;
130 app.dispatch_started_at = None;
131 app.turn_started_at = None;
132 app.turn_last_activity_at = None;
133 app.pending_turn_route = None;
134 app.pending_auto_route_receipt = None;
135 app.active_turn = None;
136 app.suppress_stream_events_until_turn_complete = false;
137 app.push_status_toast(
138 "Recovered from an inconsistent busy state.",
139 StatusToastLevel::Warning,
140 None,
141 );
142 return true;
143 }
144
145 // Branch 3: turn started but never completed — engine may have
146 // panicked, sub-agent may be stuck, or the completion event was lost.
147 if app.is_loading
148 && matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
149 && !has_running_agents
150 && !app.is_compacting
151 && !active_turn_has_running_tool(app)
152 && app
153 .turn_last_activity_at
154 .or(app.turn_started_at)
155 .is_some_and(|last_activity| {
156 now.saturating_duration_since(last_activity) > turn_stall_watchdog_timeout(app)
157 })
158 {
159 recover_stalled_runtime_turn(
160 app,
161 "Turn stalled — no completion signal received. Please try again.",
162 StatusToastLevel::Error,
163 );
164 return true;
165 }
166
167 if app.is_loading
168 && matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
169 && !has_running_agents
170 && !app.is_compacting
171 && !app.is_purging
172 && active_turn_has_running_tool(app)
173 && app
174 .turn_last_activity_at
175 .or(app.turn_started_at)
176 .is_some_and(|last_activity| {
177 now.saturating_duration_since(last_activity) > TOOL_HANG_WATCHDOG_TIMEOUT
178 })
179 {
180 recover_stalled_runtime_turn(
181 app,
182 "Tool stalled with no progress for 10m — recovered; the command may still be running in the background. Use exec_shell_cancel or retry.",
183 StatusToastLevel::Error,
184 );
185 return true;
186 }
187
188 false
189 }
190
191 /// #2739: persist the current in-memory session state before a recovery or
192 /// cancellation path clears turn bookkeeping. Without this snapshot, the
193 /// just-finalised partial turn lives only in `app.api_messages` and is never
194 /// written to disk, so `--continue` loads the *previous* save — effectively
195 /// losing the entire in-progress turn.
196 pub(crate) fn persist_recovery_snapshot(app: &mut App) {
197 if let Ok(manager) = SessionManager::default_location()
198 && let Ok(session) = build_session_snapshot(app, &manager)
199 {
200 if app.current_session_id.is_none() {
201 app.current_session_id = Some(session.metadata.id.clone());
202 }
203 if let Err(err) =
204 persist_with_pending_work_boundary(app, PersistRequest::SaveCheckpoint { session })
205 {
206 app.status_message = Some(format!(
207 "Work update is pending: recovery snapshot could not be queued ({err})"
208 ));
209 }
210 }
211 }
212
213 pub(crate) fn persist_full_reset_snapshot(app: &mut App) {
214 if let Ok(manager) = SessionManager::default_location()
215 && let Ok(session) = build_session_snapshot(app, &manager)
216 {
217 app.current_session_id = Some(session.metadata.id.clone());
218 if let Err(err) =
219 persist_with_pending_work_boundary(app, PersistRequest::SessionSnapshot(session))
220 {
221 app.status_message = Some(format!(
222 "Work update is pending: reset snapshot could not be queued ({err})"
223 ));
224 }
225 }
226 // `/clear` and `/new` are explicit boundaries. Never let an older
227 // in-flight checkpoint resurrect the session the user just discarded,
228 // even if the replacement snapshot could not be constructed.
229 // `build_session_snapshot` reuses `current_session_id`, so this id is the
230 // discarded session's id whether or not the snapshot above succeeded.
231 if let Some(session_id) = app.current_session_id.clone() {
232 persistence_actor::persist(PersistRequest::ClearCheckpoint { session_id });
233 }
234 }
235
236 pub(crate) fn maybe_throttled_recovery_snapshot(
237 app: &mut App,
238 now: Instant,
239 last_snapshot_at: &mut Option<Instant>,
240 ) {
241 if !app.is_loading && !matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
242 return;
243 }
244 if last_snapshot_at
245 .is_some_and(|last| now.saturating_duration_since(last) < RECOVERY_SNAPSHOT_INTERVAL)
246 {
247 return;
248 }
249 persist_recovery_snapshot(app);
250 *last_snapshot_at = Some(now);
251 }
252
253 pub(crate) fn recover_stalled_runtime_turn(app: &mut App, message: &str, level: StatusToastLevel) {
254 // Finalize in-flight thinking / assistant / tool cells so the
255 // transcript doesn't show permanent spinners after recovery.
256 streaming_thinking::finalize_current(app);
257 app.finalize_streaming_assistant_as_interrupted();
258 app.finalize_active_cell_as_interrupted();
259 app.streaming_state.reset();
260 app.streaming_message_index = None;
261 app.streaming_thinking_active_entry = None;
262
263 // #2739: persist the partial turn's api_messages before clearing
264 // turn state. Without this snapshot the stalled/cancelled turn's
265 // messages are held only in memory and --continue sees the
266 // *previous* save, losing the entire in-progress turn.
267 persist_recovery_snapshot(app);
268
269 app.is_loading = false;
270 app.turn_started_at = None;
271 app.turn_last_activity_at = None;
272 app.runtime_turn_status = None;
273 app.runtime_turn_id = None;
274 app.dispatch_started_at = None;
275 app.pending_turn_route = None;
276 app.pending_auto_route_receipt = None;
277 app.active_turn = None;
278 app.suppress_stream_events_until_turn_complete = false;
279 // Per-turn scroll lock — clear so the next turn auto-scrolls.
280 app.user_scrolled_during_stream = false;
281 app.push_status_toast(message, level, None);
282 }
283
284 pub(crate) fn recover_engine_event_disconnect(app: &mut App) -> bool {
285 let had_live_work = app.is_loading
286 || app.is_compacting
287 || app.is_purging
288 || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
289 || app.pending_turn_route.is_some()
290 || app.active_turn.is_some()
291 || app.suppress_stream_events_until_turn_complete
292 || app.streaming_message_index.is_some()
293 || app.streaming_thinking_active_entry.is_some()
294 || app
295 .active_cell
296 .as_ref()
297 .is_some_and(|cell| !cell.is_empty());
298
299 if !had_live_work {
300 return false;
301 }
302
303 streaming_thinking::finalize_current(app);
304 app.finalize_streaming_assistant_as_interrupted();
305 app.finalize_active_cell_as_interrupted();
306 app.streaming_state.reset();
307 app.streaming_message_index = None;
308 app.streaming_thinking_active_entry = None;
309
310 // #2739: persist partial turn before clearing state.
311 persist_recovery_snapshot(app);
312
313 app.is_loading = false;
314 app.is_compacting = false;
315 app.is_purging = false;
316 app.turn_started_at = None;
317 app.turn_last_activity_at = None;
318 app.runtime_turn_status = None;
319 app.runtime_turn_id = None;
320 app.dispatch_started_at = None;
321 app.pending_turn_route = None;
322 app.pending_auto_route_receipt = None;
323 app.active_turn = None;
324 app.suppress_stream_events_until_turn_complete = false;
325 app.user_scrolled_during_stream = false;
326
327 for msg in app.drain_pending_steers() {
328 app.queue_message(msg);
329 }
330
331 app.add_message(HistoryCell::Error {
332 message: "Engine stopped before completing the turn. Check ~/.codewhale/crashes and retry."
333 .to_string(),
334 severity: crate::error_taxonomy::ErrorSeverity::Error,
335 });
336 app.push_status_toast(
337 "Engine stopped before completing the turn.",
338 StatusToastLevel::Error,
339 None,
340 );
341 true
342 }
343
344 pub(crate) fn capture_turn_started_metadata(app: &mut App, event: &EngineEvent) {
345 match event {
346 EngineEvent::TurnStarted {
347 turn_id,
348 created_at,
349 route,
350 } => {
351 app.ocean_completion_started_at = None;
352 let auto_route_receipt = if route.as_ref().is_some_and(|route| route.auto_model) {
353 app.pending_auto_route_receipt.take()
354 } else if route.is_some() {
355 app.pending_auto_route_receipt = None;
356 None
357 } else {
358 None
359 };
360 // Bind the prompt-suggestion authority to the receipt the engine minted
361 // from the client it installed for this turn. Deliberately not read
362 // from `config`: web config events are drained ahead of engine events,
363 // so config here may already describe a different key or endpoint than
364 // the one this turn is actually running on.
365 let suggestion_authority = route
366 .as_ref()
367 .and_then(crate::tui::prompt_suggestion::capture_route_authority);
368 app.active_turn = Some(ActiveTurnMetadata {
369 turn_id: turn_id.clone(),
370 created_at: *created_at,
371 route: route.clone(),
372 auto_route_receipt,
373 suggestion_authority,
374 });
375 app.pending_turn_route = None;
376 }
377 // The dispatch boundary is the billing truth: refresh the active turn's
378 // route with the envelope that was actually put on the wire. Receipts
379 // already taken at `TurnStarted` are preserved — this event narrows the
380 // route, it never re-opens an authority decision.
381 EngineEvent::RouteDispatched { turn_id, route } => {
382 if let Some(active) = app
383 .active_turn
384 .as_mut()
385 .filter(|active| active.turn_id == *turn_id)
386 {
387 if route.auto_model && active.auto_route_receipt.is_none() {
388 active.auto_route_receipt = app.pending_auto_route_receipt.take();
389 } else if !route.auto_model {
390 app.pending_auto_route_receipt = None;
391 active.auto_route_receipt = None;
392 }
393 if active.suggestion_authority.is_none() {
394 active.suggestion_authority =
395 crate::tui::prompt_suggestion::capture_route_authority(route);
396 }
397 active.route = Some(route.clone());
398 }
399 }
400 _ => {}
401 }
402 }
403
404 pub(crate) fn record_turn_activity(app: &mut App, event: &EngineEvent, now: Instant) {
405 if matches!(event, EngineEvent::TurnStarted { .. }) {
406 app.turn_last_activity_at = Some(now);
407 return;
408 }
409
410 if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
411 app.turn_last_activity_at = Some(now);
412 }
413 }
414
415 pub(crate) fn persist_offline_queue_state(app: &App) {
416 if app.queued_messages.is_empty() && app.queued_draft.is_none() {
417 persistence_actor::persist(PersistRequest::ClearOfflineQueue);
418 return;
419 }
420 let state = OfflineQueueState {
421 messages: app
422 .queued_messages
423 .iter()
424 .map(queued_ui_to_session)
425 .collect(),
426 draft: app.queued_draft.as_ref().map(queued_ui_to_session),
427 ..OfflineQueueState::default()
428 };
429 persistence_actor::persist(PersistRequest::OfflineQueue {
430 state,
431 session_id: app.current_session_id.clone(),
432 });
433 }
434
435 pub(crate) fn restore_queued_message(app: &mut App, index: Option<usize>, message: QueuedMessage) {
436 if let Some(index) = index
437 && index <= app.queued_messages.len()
438 {
439 app.queued_messages.insert(index, message);
440 } else {
441 app.queue_message(message);
442 }
443 }
444
445 pub(crate) fn restore_queued_or_draft_message(
446 app: &mut App,
447 recovery: DispatchRecovery,
448 message: QueuedMessage,
449 ) {
450 match recovery {
451 DispatchRecovery::Draft => {
452 app.input.clone_from(&message.display);
453 app.cursor_position = app.input.chars().count();
454 app.active_skill = message.skill_instruction.clone();
455 app.active_skill_provenance = message.skill_provenance.clone();
456 app.queued_draft = Some(message);
457 app.needs_redraw = true;
458 }
459 DispatchRecovery::Queued { restore_index } => {
460 restore_queued_message(app, restore_index, message);
461 }
462 DispatchRecovery::Immediate | DispatchRecovery::Initial => app.queue_message(message),
463 }
464 }
465
466 pub(crate) fn recover_unstarted_external_message(
467 app: &mut App,
468 message: QueuedMessage,
469 recovery: DispatchRecovery,
470 error: &str,
471 ) {
472 app.dispatch_in_flight = false;
473 match recovery {
474 DispatchRecovery::Immediate | DispatchRecovery::Initial => {
475 restore_failed_immediate_submit(app, message, &anyhow::Error::msg(error.to_string()));
476 }
477 DispatchRecovery::Draft => {
478 restore_queued_or_draft_message(app, recovery, message);
479 app.status_message = Some(format!("{error}; queued draft restored"));
480 }
481 DispatchRecovery::Queued { restore_index } => {
482 restore_queued_message(app, restore_index, message);
483 app.status_message = Some(format!(
484 "{error}; {} queued follow-up(s) restored",
485 app.queued_message_count()
486 ));
487 }
488 }
489 app.push_status_toast(
490 error.to_string(),
491 StatusToastLevel::Error,
492 Some(App::STICKY_ERROR_TTL_MS),
493 );
494 app.needs_redraw = true;
495 }
496
497 pub(crate) fn restore_message_submit_denial(
498 app: &mut App,
499 message: QueuedMessage,
500 recovery: DispatchRecovery,
501 ) {
502 let denial = app
503 .status_message
504 .clone()
505 .unwrap_or_else(|| "message_submit hook blocked submission".to_string());
506 app.dispatch_in_flight = false;
507 match recovery {
508 DispatchRecovery::Immediate | DispatchRecovery::Initial => {
509 app.input.clone_from(&message.display);
510 app.cursor_position = app.input.chars().count();
511 app.active_skill = message.skill_instruction;
512 app.active_skill_provenance = message.skill_provenance;
513 }
514 DispatchRecovery::Draft => {
515 restore_queued_or_draft_message(app, recovery, message);
516 }
517 DispatchRecovery::Queued { restore_index } => {
518 restore_queued_message(app, restore_index, message);
519 }
520 }
521 app.status_message = Some(denial.clone());
522 app.push_status_toast(denial, StatusToastLevel::Warning, Some(6_000));
523 app.needs_redraw = true;
524 }
525
526 pub(crate) fn launch_worktree_slug(requested: &str) -> String {
527 let requested = requested.trim();
528 if requested.is_empty() {
529 return format!("session-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S"));
530 }
531 let mut slug = String::new();
532 let mut separator = false;
533 for ch in requested.chars() {
534 if ch.is_ascii_alphanumeric() {
535 slug.push(ch.to_ascii_lowercase());
536 separator = false;
537 } else if matches!(ch, '-' | '_' | ' ' | '/' | '.') && !slug.is_empty() && !separator {
538 slug.push('-');
539 separator = true;
540 }
541 }
542 while slug.ends_with('-') {
543 slug.pop();
544 }
545 if slug.is_empty() {
546 format!("session-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S"))
547 } else {
548 slug
549 }
550 }
551
552 pub(crate) fn launch_worktree_spec(
553 workspace: &std::path::Path,
554 requested: &str,
555 ) -> Result<codewhale_lane::WorktreeProvision> {
556 let output = std::process::Command::new("git")
557 .current_dir(workspace)
558 .args(["rev-parse", "--show-toplevel"])
559 .output()
560 .context("inspect Git repository for new worktree")?;
561 if !output.status.success() {
562 anyhow::bail!("new worktree requires a Git repository");
563 }
564 let repo_root = PathBuf::from(String::from_utf8(output.stdout)?.trim());
565 let repo_name = repo_root
566 .file_name()
567 .and_then(|name| name.to_str())
568 .filter(|name| !name.is_empty())
569 .unwrap_or("workspace");
570 let slug = launch_worktree_slug(requested);
571 let parent = repo_root.parent().unwrap_or(repo_root.as_path());
572 let path = parent
573 .join(".codewhale-worktrees")
574 .join(format!("{repo_name}-{slug}"));
575 if path.exists() {
576 anyhow::bail!("worktree path already exists: {}", path.display());
577 }
578 Ok(codewhale_lane::WorktreeProvision {
579 repo_root,
580 branch: format!("codex/{slug}"),
581 path,
582 base_ref: Some("HEAD".to_string()),
583 })
584 }
585
586 pub(crate) async fn provision_launch_worktree(
587 workspace: PathBuf,
588 requested: String,
589 ) -> Result<PathBuf> {
590 let spec = launch_worktree_spec(&workspace, &requested)?;
591 let provisioned =
592 tokio::task::spawn_blocking(move || codewhale_lane::provision_worktree(&spec))
593 .await
594 .context("new worktree task failed")??;
595 Ok(provisioned.path)
596 }
597
598 pub(crate) fn begin_launch_session(
599 app: &mut App,
600 workspace: Option<PathBuf>,
601 ) -> commands::CommandResult {
602 if let Some(workspace) = workspace {
603 app.workspace = workspace;
604 }
605 let session_id = uuid::Uuid::new_v4().to_string();
606 app.current_session_id = Some(session_id.clone());
607 app.current_session_metadata = None;
608 app.session_title = Some(app.tr(MessageId::SessionsNewSessionTitle).into_owned());
609 app.launch.visible = false;
610 app.launch.status = None;
611 app.status_message = None;
612 commands::CommandResult::action(AppAction::SyncSession {
613 session_id: Some(session_id),
614 messages: Vec::new(),
615 system_prompt: None,
616 model: app.model.clone(),
617 workspace: app.workspace.clone(),
618 mode: app.mode,
619 })
620 }
621
622 pub(crate) async fn sync_runtime_workspace_state(
623 task_manager: &SharedTaskManager,
624 workspace: PathBuf,
625 ) {
626 task_manager.set_default_workspace(workspace).await;
627 }
628
629 pub(crate) async fn switch_workspace(
630 app: &mut App,
631 engine_handle: &mut EngineHandle,
632 task_manager: &SharedTaskManager,
633 config: &Config,
634 workspace: PathBuf,
635 ) {
636 if app.is_loading {
637 app.status_message =
638 Some("Cannot switch workspace while a request is running.".to_string());
639 app.add_message(HistoryCell::System {
640 content: "Cannot switch workspace while a request is running.".to_string(),
641 });
642 return;
643 }
644
645 if app.workspace == workspace {
646 app.status_message = Some(format!("Workspace unchanged: {}", workspace.display()));
647 return;
648 }
649
650 apply_workspace_runtime_state(app, config, workspace.clone());
651 sync_runtime_workspace_state(task_manager, workspace.clone()).await;
652
653 let _ = engine_handle.send(Op::Shutdown).await;
654 let engine_config = build_engine_config(app, config);
655 *engine_handle = spawn_tui_engine(engine_config, config);
656 if !app.api_messages.is_empty() {
657 let _ = engine_handle
658 .send(Op::SyncSession {
659 session_id: app.current_session_id.clone(),
660 messages: app.api_messages.clone(),
661 system_prompt: app.system_prompt.clone(),
662 system_prompt_override: false,
663 model: app.model.clone(),
664 workspace: workspace.clone(),
665 mode: app.mode,
666 })
667 .await;
668 }
669
670 app.add_message(HistoryCell::System {
671 content: format!("Switched workspace to {}", workspace.display()),
672 });
673 app.status_message = Some(format!("Workspace: {}", workspace.display()));
674 }
675
676 pub(crate) fn restore_failed_immediate_submit(
677 app: &mut App,
678 message: QueuedMessage,
679 error: &anyhow::Error,
680 ) {
681 tracing::warn!(
682 error = %error,
683 "immediate user message dispatch failed; restored composer"
684 );
685 app.input = message.display;
686 app.cursor_position = app.input.chars().count();
687 app.active_skill = message.skill_instruction;
688 app.active_skill_provenance = message.skill_provenance;
689 let status = tr(app.ui_locale, MessageId::ComposerDispatchFailedRestored)
690 .replace("{error}", &error.to_string());
691 app.status_message = Some(status.clone());
692 app.set_sticky_status(
693 status,
694 StatusToastLevel::Error,
695 Some(App::STICKY_ERROR_TTL_MS),
696 );
697 app.needs_redraw = true;
698 }
699
700 /// Show the default recommended Hotbar slots. Since #3807 an absent `hotbar`
701 /// key means "hidden", so `/hotbar on` persists the explicit default bindings
702 /// rather than deleting the key. This is an explicit reset, so any custom
703 /// bindings are replaced with the recommended set.
704 pub(crate) fn restore_hotbar_defaults(app: &mut App, config: &mut Config) {
705 let defaults = codewhale_config::default_hotbar_bindings_toml();
706 match crate::config_persistence::persist_hotbar_bindings(app.config_path.as_deref(), &defaults)
707 {
708 Ok(path) => {
709 config.hotbar = Some(defaults);
710 app.status_message = Some(format!(
711 "Hotbar enabled with the default slots ({}). Customize with `/hotbar`.",
712 path.display()
713 ));
714 }
715 Err(err) => {
716 app.status_message = Some(format!("Failed to enable the Hotbar: {err}"));
717 app.add_message(HistoryCell::System {
718 content: format!("Failed to enable the Hotbar: {err}"),
719 });
720 }
721 }
722 app.needs_redraw = true;
723 }
724
725 pub(crate) fn persist_rules_from_approval(
726 app: &mut App,
727 config: &mut Config,
728 rules: &[codewhale_config::ToolAskRule],
729 ) {
730 let action = rules.first().map(|rule| rule.action);
731 match codewhale_config::ConfigStore::load(app.config_path.clone()).and_then(|mut store| {
732 let added = match action {
733 Some(codewhale_execpolicy::PermissionAction::Ask) => store.append_ask_rules(rules)?,
734 Some(codewhale_execpolicy::PermissionAction::Allow) => {
735 store.append_allow_rules(rules)?
736 }
737 Some(codewhale_execpolicy::PermissionAction::Deny) => {
738 anyhow::bail!("the approval UI cannot persist deny rules")
739 }
740 None => 0,
741 };
742 let permissions_path = store.permissions_path();
743 config.exec_policy_engine = store.exec_policy_engine();
744 Ok((added, permissions_path))
745 }) {
746 Ok((added, path)) if added > 0 => {
747 let action = match action {
748 Some(codewhale_execpolicy::PermissionAction::Allow) => "allow",
749 _ => "ask",
750 };
751 app.status_message = Some(format!(
752 "Saved {added} {action} permission rule(s) to {}",
753 path.display()
754 ));
755 }
756 Ok((_added, path)) => {
757 let action = match action {
758 Some(codewhale_execpolicy::PermissionAction::Allow) => "Allow",
759 _ => "Ask",
760 };
761 app.status_message = Some(format!(
762 "{action} permission rule already saved in {}",
763 path.display()
764 ));
765 }
766 Err(err) => {
767 app.status_message = Some(format!("Failed to save permission rule: {err:#}"));
768 }
769 }
770 }
771
772 pub(crate) fn mirror_saved_model_in_config(
773 config: &mut Config,
774 provider: ApiProvider,
775 model: String,
776 ) {
777 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
778 config.default_text_model = Some(model);
779 return;
780 }
781 config.set_provider_model_override(provider, Some(model));
782 }
783
784 pub(crate) fn mirror_saved_context_window_in_config(
785 config: &mut Config,
786 provider: ApiProvider,
787 context_window: u32,
788 ) {
789 let providers = config
790 .providers
791 .get_or_insert_with(ProvidersConfig::default);
792 let entry = match provider {
793 ApiProvider::Moonshot => &mut providers.moonshot,
794 _ => return,
795 };
796 entry.context_window = Some(context_window);
797 }
798
799 pub(crate) fn mirror_saved_api_key_in_config(
800 config: &mut Config,
801 provider: ApiProvider,
802 api_key: String,
803 ) {
804 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
805 config.api_key = Some(api_key);
806 config.auth_mode = Some("api_key".to_string());
807 return;
808 }
809 if provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route() {
810 config.api_key = Some(api_key);
811 config.auth_mode = Some("api_key".to_string());
812 return;
813 }
814 let pin_kimi_code_base_url = provider == ApiProvider::Moonshot
815 && config.provider_config_for(provider).is_some_and(|entry| {
816 crate::config::provider_config_uses_kimi_imported_token(entry)
817 && entry
818 .base_url
819 .as_deref()
820 .is_none_or(|base_url| base_url.trim().is_empty())
821 });
822 let custom_key = (provider == ApiProvider::Custom).then(|| {
823 config
824 .provider
825 .clone()
826 .unwrap_or_else(|| "__custom__".to_string())
827 });
828 let providers = config
829 .providers
830 .get_or_insert_with(ProvidersConfig::default);
831 let entry: &mut ProviderConfig = match provider {
832 ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,
833 ApiProvider::Custom => providers
834 .custom
835 .entry(custom_key.expect("custom key captured for custom provider"))
836 .or_default(),
837 ApiProvider::DeepseekAnthropic => &mut providers.deepseek_anthropic,
838 ApiProvider::NvidiaNim => &mut providers.nvidia_nim,
839 ApiProvider::Openai => &mut providers.openai,
840 ApiProvider::Atlascloud => &mut providers.atlascloud,
841 ApiProvider::WanjieArk => &mut providers.wanjie_ark,
842 ApiProvider::Volcengine => &mut providers.volcengine,
843 ApiProvider::Openrouter => &mut providers.openrouter,
844 ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo,
845 ApiProvider::Novita => &mut providers.novita,
846 ApiProvider::Fireworks => &mut providers.fireworks,
847 ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => &mut providers.siliconflow,
848 ApiProvider::Arcee => &mut providers.arcee,
849 ApiProvider::Moonshot => &mut providers.moonshot,
850 ApiProvider::Sglang => &mut providers.sglang,
851 ApiProvider::Vllm => &mut providers.vllm,
852 ApiProvider::Ollama => &mut providers.ollama,
853 ApiProvider::Huggingface => &mut providers.huggingface,
854 ApiProvider::Deepinfra => &mut providers.deepinfra,
855 ApiProvider::Together => &mut providers.together,
856 ApiProvider::Qianfan => &mut providers.qianfan,
857 ApiProvider::OpenaiCodex => &mut providers.openai_codex,
858 ApiProvider::Anthropic => &mut providers.anthropic,
859 ApiProvider::Openmodel => &mut providers.openmodel,
860 ApiProvider::Zai => &mut providers.zai,
861 ApiProvider::Stepfun => &mut providers.stepfun,
862 ApiProvider::Minimax => &mut providers.minimax,
863 ApiProvider::MinimaxAnthropic => &mut providers.minimax_anthropic,
864 ApiProvider::Sakana => &mut providers.sakana,
865 ApiProvider::LongCat => &mut providers.longcat,
866 ApiProvider::OpencodeGo => &mut providers.opencode_go,
867 ApiProvider::OpencodeZen => &mut providers.opencode_zen,
868 ApiProvider::Meta => &mut providers.meta,
869 ApiProvider::Xai => &mut providers.xai,
870 ApiProvider::Telecomjs => &mut providers.telecomjs,
871 ApiProvider::ModelstudioTokenPlan => &mut providers.modelstudio_token_plan,
872 ApiProvider::ModelstudioTokenPlanAnthropic => {
873 &mut providers.modelstudio_token_plan_anthropic
874 }
875 ApiProvider::ModelstudioCodingPlan => &mut providers.modelstudio_coding_plan,
876 ApiProvider::ModelstudioCodingPlanAnthropic => {
877 &mut providers.modelstudio_coding_plan_anthropic
878 }
879 };
880 if pin_kimi_code_base_url {
881 entry.base_url = Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string());
882 }
883 entry.auth_mode = Some("api_key".to_string());
884 entry.api_key = Some(api_key);
885 entry.external_credentials = None;
886 if provider == ApiProvider::Xai {
887 entry.oauth_credential_generation = None;
888 }
889 }
890
891 pub(crate) fn loaded_session_requires_engine_respawn(
892 app: &App,
893 previous_provider: ApiProvider,
894 previous_provider_identity: &str,
895 previous_workspace: &Path,
896 ) -> bool {
897 app.api_provider != previous_provider
898 || app.provider_identity_for_persistence() != previous_provider_identity
899 || app.workspace != previous_workspace
900 }
901
902 pub(crate) fn restore_loaded_session_provider(
903 app: &mut App,
904 config: &mut Config,
905 identity: ProviderIdentity,
906 ) {
907 let provider = identity.provider;
908 config.provider = Some(identity.key.clone());
909 app.set_provider_identity_record(identity);
910 app.billing_presentation = crate::route_billing::for_route(config, provider);
911 app.max_subagents = config
912 .max_subagents_for_provider(provider)
913 .clamp(1, crate::config::MAX_SUBAGENTS);
914 app.provider_chain = provider
915 .kind()
916 .map(|kind| codewhale_config::ProviderChain::new(kind, &config.fallback_providers))
917 .filter(|chain| chain.providers().len() > 1);
918 app.last_fallback_reason = None;
919 app.model_ids_passthrough = config.model_ids_pass_through();
920 if !app.auto_model {
921 let requested = app
922 .reasoning_effort_preference
923 .unwrap_or(app.reasoning_effort);
924 app.reasoning_effort =
925 requested.normalize_for_route(provider, &config.deepseek_base_url(), &app.model);
926 }
927 app.set_active_context_window_override(config.context_window_for_provider_config(provider));
928 app.active_route_limits = app.context_window_override_limits();
929 app.active_route_base_url = config.deepseek_base_url();
930 app.active_context_window_source = if app.active_context_window_override.is_some() {
931 crate::route_runtime::ContextWindowSource::Configured
932 } else {
933 crate::route_runtime::ContextWindowSource::Fallback
934 };
935 }
936
937 pub(crate) fn resolve_loaded_session_route(app: &mut App, config: &Config) {
938 let context_override = config.context_window_for_provider_config(app.api_provider);
939 app.set_active_context_window_override(context_override);
940 if app.auto_model {
941 app.active_route_limits = app.context_window_override_limits();
942 app.active_route_base_url = config.deepseek_base_url();
943 app.active_context_window_source = if context_override.is_some() {
944 crate::route_runtime::ContextWindowSource::Configured
945 } else {
946 crate::route_runtime::ContextWindowSource::Fallback
947 };
948 return;
949 }
950
951 let saved_provider_model = config
952 .provider_config_for(app.api_provider)
953 .and_then(|provider| provider.model.as_deref());
954 match crate::route_runtime::resolve_route_candidate_with_context_metadata(
955 app.api_provider,
956 Some(&app.model),
957 saved_provider_model,
958 Some(config.deepseek_base_url()),
959 context_override,
960 None,
961 ) {
962 Ok(resolution) => app.set_active_route_resolution(
963 resolution.candidate.endpoint().base_url.clone(),
964 resolution.candidate.limits(),
965 resolution.context_window.source,
966 ),
967 Err(_) => {
968 app.active_route_limits = app.context_window_override_limits();
969 app.active_route_base_url = config.deepseek_base_url();
970 app.active_context_window_source = if context_override.is_some() {
971 crate::route_runtime::ContextWindowSource::Configured
972 } else {
973 crate::route_runtime::ContextWindowSource::Fallback
974 };
975 }
976 }
977 }
978
979 /// Derive a short display title from the API message list.
980 ///
981 /// Tries several strategies in order:
982 /// 1. If the first user message starts with a known slash command (`/goal`,
983 /// `/fleet`, `/workflow`, etc.), use the command + first argument.
984 /// 2. Otherwise, take the first meaningful line and cut it at a natural
985 /// phrase boundary (period, comma, colon, or word boundary) within
986 /// `SESSION_TITLE_MAX_CHARS`, never splitting mid-word.
987 ///
988 /// Never leaks raw prompt text — the result is always a concise label.
989 pub(crate) fn derive_session_title(messages: &[Message]) -> Option<String> {
990 let text = messages.iter().find(|m| m.role == "user").and_then(|m| {
991 m.content.iter().find_map(|block| match block {
992 ContentBlock::Text { text, .. } if !text.starts_with(TURN_META_PREFIX) => {
993 Some(text.trim().to_string())
994 }
995 _ => None,
996 })
997 })?;
998
999 let first_line = text.lines().next().unwrap_or("").trim();
1000 if first_line.is_empty() {
1001 return None;
1002 }
1003
1004 // Slash command: extract command name + first reasonable argument.
1005 if let Some(rest) = first_line.strip_prefix('/') {
1006 let parts: Vec<&str> = rest.split_whitespace().collect();
1007 return match parts.as_slice() {
1008 [] => None,
1009 [cmd] => Some(format!("/{cmd}")),
1010 [cmd, arg, ..] => {
1011 let arg_short = short_title_truncate(arg, 24);
1012 Some(format!("/{cmd} {arg_short}"))
1013 }
1014 };
1015 }
1016
1017 Some(short_title_truncate(first_line, SESSION_TITLE_MAX_CHARS))
1018 }
1019
1019 lines RUST