返回 CodeWhale
compaction_flow.rs
根目录 / crates / tui / src / tui / ui / compaction_flow.rs
1 //! Compaction UI state: manual/automatic compaction queueing, settlement,
2 //! receipts, and cancel behavior (TUI_MODULARIZATION.md slice 7). The engine
3 //! owns the actual summarization; this module projects its lifecycle into the
4 //! UI and never awaits the bounded engine mailbox from the event loop.
5
6 use super::*;
7
8 /// Queue a live compaction update without waiting on the engine mailbox.
9 ///
10 /// Config edits are valid while a turn is streaming, but awaiting a bounded
11 /// engine mailbox from the UI event loop can make the whole TUI appear frozen
12 /// when the turn is busy. A dropped refresh is safe: the next turn rebuilds
13 /// its compaction config from `App`, and the status message tells the user
14 /// whether the update was queued or deferred.
15 pub(crate) fn try_apply_model_and_compaction_update(
16 engine_handle: &EngineHandle,
17 compaction: crate::compaction::CompactionConfig,
18 mode: AppMode,
19 route_limits: Option<codewhale_config::route::RouteLimits>,
20 ) -> bool {
21 if engine_handle
22 .try_send(Op::SetModel {
23 model: compaction.model.clone(),
24 mode,
25 route_limits,
26 })
27 .is_err()
28 {
29 return false;
30 }
31 engine_handle
32 .try_send(Op::SetCompaction { config: compaction })
33 .is_ok()
34 }
35
36 pub(crate) fn set_explicit_compaction_status(
37 app: &mut App,
38 text: String,
39 level: StatusToastLevel,
40 sticky: bool,
41 ) {
42 app.status_message = Some(text.clone());
43 // This lifecycle reducer assigns the semantic level explicitly. Mark the
44 // legacy status bridge as synchronized so it cannot add a second,
45 // keyword-classified toast with a different level on the next frame.
46 app.last_status_message_seen = Some(text.clone());
47 if sticky {
48 app.set_sticky_status(text, level, Some(App::STICKY_ERROR_TTL_MS));
49 } else {
50 app.push_status_toast(text, level, Some(5_000));
51 }
52 }
53
54 /// Queue manual compaction without ever awaiting the bounded engine mailbox
55 /// from the terminal event loop.
56 ///
57 /// During an active turn, a successful send is intentionally deferred until
58 /// the engine returns to its outer operation loop. Full and closed mailboxes
59 /// are rejected immediately with an actionable receipt, so `/compact` cannot
60 /// freeze keyboard input or rendering.
61 pub(crate) fn try_queue_manual_compaction(
62 app: &mut App,
63 config: &Config,
64 engine_handle: &EngineHandle,
65 focus: Option<String>,
66 ) {
67 if app.is_compacting || app.manual_compaction_queued {
68 let text = app
69 .tr(MessageId::ContextCompactionAlreadyRunning)
70 .into_owned();
71 add_compaction_receipt(app, &text);
72 set_explicit_compaction_status(app, text, StatusToastLevel::Warning, false);
73 return;
74 }
75
76 let route = match validated_app_runtime_route(app, config) {
77 Ok(route) => route,
78 Err(error) => {
79 let text = app
80 .tr(MessageId::ContextCompactionRouteInvalid)
81 .replace("{error}", &error.to_string());
82 add_compaction_receipt(app, &text);
83 set_explicit_compaction_status(app, text, StatusToastLevel::Error, true);
84 return;
85 }
86 };
87 let mut compaction = compaction_for_validated_route(app, &route);
88 compaction.focus = focus.clone();
89 let request_id = format!("compact_{}", &uuid::Uuid::new_v4().to_string()[..8]);
90 let op = Op::CompactContext {
91 id: request_id.clone(),
92 route: Box::new(route.into_resolved()),
93 compaction: Box::new(compaction),
94 };
95
96 match engine_handle.try_send(op) {
97 Ok(()) => {
98 app.manual_compaction_queued = true;
99 app.manual_compaction_id = Some(request_id);
100 let id = if app.is_loading {
101 MessageId::ContextCompactionQueued
102 } else {
103 MessageId::ContextManualCompacting
104 };
105 let text = app.tr(id).into_owned();
106 // Queued-behind-a-turn is a state the user must be able to find
107 // again after the 5s toast: leave it in the transcript too.
108 if app.is_loading {
109 add_compaction_receipt(app, &text);
110 }
111 set_explicit_compaction_status(app, text, StatusToastLevel::Info, false);
112 }
113 Err(error) => {
114 let full = error
115 .downcast_ref::<tokio::sync::mpsc::error::TrySendError<Op>>()
116 .is_some_and(|send_error| {
117 matches!(send_error, tokio::sync::mpsc::error::TrySendError::Full(_))
118 });
119 if full {
120 // A saturated mailbox is a timing accident of the active turn,
121 // not a user error. Queue client-side and let the event loop
122 // retry once the engine drains a slot; the user sees the same
123 // queued receipt as the ordinary behind-a-turn path.
124 app.manual_compaction_queued = true;
125 app.manual_compaction_id = Some(request_id);
126 app.deferred_manual_compaction = Some(focus);
127 let text = app.tr(MessageId::ContextCompactionQueued).into_owned();
128 add_compaction_receipt(app, &text);
129 set_explicit_compaction_status(app, text, StatusToastLevel::Info, false);
130 } else {
131 let text = app.tr(MessageId::ContextCompactionQueueClosed).into_owned();
132 add_compaction_receipt(app, &text);
133 set_explicit_compaction_status(app, text, StatusToastLevel::Error, true);
134 }
135 }
136 }
137 }
138
139 /// Retry a manual compaction that was deferred by a full engine mailbox.
140 ///
141 /// Called once per event-loop iteration. Silent by design: the queued receipt
142 /// was already written when the request was deferred, a still-full mailbox
143 /// just waits for the next iteration, and a compaction that started or
144 /// settled in the meantime supersedes the request entirely (handled by
145 /// `apply_compaction_started`/`settle_compaction`).
146 pub(crate) fn flush_deferred_manual_compaction(
147 app: &mut App,
148 config: &Config,
149 engine_handle: &EngineHandle,
150 ) {
151 if app.deferred_manual_compaction.is_none() || app.is_compacting {
152 return;
153 }
154 let route = match validated_app_runtime_route(app, config) {
155 Ok(route) => route,
156 Err(error) => {
157 app.deferred_manual_compaction = None;
158 app.manual_compaction_queued = false;
159 app.manual_compaction_id = None;
160 let text = app
161 .tr(MessageId::ContextCompactionRouteInvalid)
162 .replace("{error}", &error.to_string());
163 add_compaction_receipt(app, &text);
164 set_explicit_compaction_status(app, text, StatusToastLevel::Error, true);
165 return;
166 }
167 };
168 let focus = app.deferred_manual_compaction.clone().unwrap_or_default();
169 let Some(request_id) = app.manual_compaction_id.clone() else {
170 app.deferred_manual_compaction = None;
171 app.manual_compaction_queued = false;
172 return;
173 };
174 let mut compaction = compaction_for_validated_route(app, &route);
175 compaction.focus = focus;
176 let op = Op::CompactContext {
177 id: request_id,
178 route: Box::new(route.into_resolved()),
179 compaction: Box::new(compaction),
180 };
181 match engine_handle.try_send(op) {
182 Ok(()) => {
183 app.deferred_manual_compaction = None;
184 }
185 Err(error) => {
186 let full = error
187 .downcast_ref::<tokio::sync::mpsc::error::TrySendError<Op>>()
188 .is_some_and(|send_error| {
189 matches!(send_error, tokio::sync::mpsc::error::TrySendError::Full(_))
190 });
191 if !full {
192 app.deferred_manual_compaction = None;
193 app.manual_compaction_queued = false;
194 app.manual_compaction_id = None;
195 let text = app.tr(MessageId::ContextCompactionQueueClosed).into_owned();
196 add_compaction_receipt(app, &text);
197 set_explicit_compaction_status(app, text, StatusToastLevel::Error, true);
198 }
199 }
200 }
201 }
202
203 pub(crate) fn apply_compaction_started(app: &mut App, id: String, auto: bool) {
204 if app.sticky_status.as_ref().is_some_and(|status| {
205 matches!(
206 status.kind,
207 crate::tui::app::StatusToastKind::ContextPressure(_)
208 )
209 }) {
210 app.clear_sticky_status();
211 }
212 app.context_pressure_warning_dismissed = None;
213 if !auto {
214 app.manual_compaction_queued = false;
215 if app.manual_compaction_id.as_deref() == Some(id.as_str()) {
216 app.manual_compaction_id = None;
217 }
218 }
219 // A compaction is running; a deferred manual request is now redundant.
220 // Dropping it must also release the queued flag when the running pass is
221 // automatic, or `/compact` would report "already in progress" forever.
222 if app.deferred_manual_compaction.take().is_some() && auto {
223 app.manual_compaction_queued = false;
224 app.manual_compaction_id = None;
225 }
226 app.active_compaction = Some(ActiveCompaction { id, auto });
227 app.is_compacting = true;
228 if !auto {
229 let text = app.tr(MessageId::ContextManualCompacting).into_owned();
230 set_explicit_compaction_status(app, text, StatusToastLevel::Info, false);
231 }
232 }
233
234 /// Clear the compaction-in-flight state for a terminal lifecycle event.
235 ///
236 /// An exact id match clears normally. A terminal event with NO tracked
237 /// compaction is still authoritative (the started event can be lost to a
238 /// dropped drain or session switch): without this, `is_compacting`/
239 /// `manual_compaction_queued` stayed latched and every later `/compact` was
240 /// silently rejected as "already in progress". A stale event while a NEWER
241 /// compaction is live must not clear it (or report anything) — that live
242 /// pass gets its own terminal event. Returns whether the event settled.
243 pub(crate) fn settle_compaction(app: &mut App, id: &str, auto: bool) -> bool {
244 if app
245 .active_compaction
246 .as_ref()
247 .is_some_and(|active| active.id != id || active.auto != auto)
248 {
249 return false;
250 }
251 app.active_compaction = None;
252 app.is_compacting = false;
253 if !auto {
254 app.manual_compaction_queued = false;
255 app.manual_compaction_id = None;
256 }
257 // A settled pass makes a still-deferred manual request redundant (the
258 // context was just compacted). Dropping it releases the queued flag so a
259 // later `/compact` is not rejected as "already in progress".
260 if app.deferred_manual_compaction.take().is_some() {
261 app.manual_compaction_queued = false;
262 app.manual_compaction_id = None;
263 }
264 true
265 }
266
267 /// Durable transcript receipt for a compaction outcome.
268 ///
269 /// Outcome feedback used to be toast-only, and the engine emits
270 /// `TurnComplete` immediately after the compaction event — both land in the
271 /// same UI drain batch, so the turn's "done" status replaced the completion
272 /// toast before a single frame was drawn. `/compact` looked like a no-op
273 /// even when the summary committed (the v0.9.6 release blocker).
274 pub(crate) fn add_compaction_receipt(app: &mut App, message: &str) {
275 app.add_message(HistoryCell::System {
276 content: message.to_string(),
277 });
278 }
279
280 pub(crate) fn apply_compaction_completed(
281 app: &mut App,
282 id: &str,
283 auto: bool,
284 message: String,
285 messages_before: Option<usize>,
286 messages_after: Option<usize>,
287 summary_prompt: Option<String>,
288 ) {
289 if settle_compaction(app, id, auto) {
290 // The billed prompt receipt described the pre-compaction context;
291 // after the rewrite the local estimate is the honest signal until
292 // the next model call bills the new, smaller prompt (#5577).
293 app.last_billed_input_tokens = None;
294 let keep = crate::compaction::inspect_compaction_keep(&app.api_messages);
295 let path = if summary_prompt
296 .as_deref()
297 .is_some_and(|text| !text.trim().is_empty())
298 {
299 crate::compaction::CompactionPath::Summary
300 } else {
301 crate::compaction::CompactionPath::PruneOnly
302 };
303 let after = messages_after.unwrap_or(app.api_messages.len());
304 let before = messages_before.unwrap_or(after);
305 app.last_compaction = Some(crate::compaction::LastCompactionSnapshot {
306 auto,
307 coverage: crate::compaction::CompactionCoverage {
308 path,
309 last_round_messages: keep.last_round_messages,
310 last_round_tool_results: keep.last_round_tool_results,
311 last_round_assistant: keep.last_round_assistant,
312 dropped_messages: before.saturating_sub(after),
313 anchors_chars: crate::compaction::pinned_anchors_text(Some(&app.workspace))
314 .map(|text| text.chars().count())
315 .unwrap_or(0),
316 // Only the summary path builds a replacement history, so only
317 // it spent a verbatim budget (#5956).
318 retained_user_message_tokens: match path {
319 crate::compaction::CompactionPath::Summary => {
320 app.compaction_retained_user_message_tokens
321 }
322 crate::compaction::CompactionPath::PruneOnly => 0,
323 },
324 operator_instructions_applied: matches!(
325 path,
326 crate::compaction::CompactionPath::Summary
327 ) && app.compaction_summary_instructions.is_some(),
328 },
329 messages_before: before,
330 messages_after: after,
331 });
332 // Automatic maintenance stays in the context inspector and event
333 // receipts; it does not insert a ceremony into the user's task.
334 if !auto {
335 add_compaction_receipt(app, &message);
336 set_explicit_compaction_status(app, message, StatusToastLevel::Success, false);
337 }
338 }
339 }
340
341 pub(crate) fn apply_compaction_failed(app: &mut App, id: &str, auto: bool, message: String) {
342 if settle_compaction(app, id, auto) {
343 add_compaction_receipt(app, &message);
344 set_explicit_compaction_status(app, message, StatusToastLevel::Error, true);
345 }
346 }
347
348 pub(crate) fn apply_compaction_cancelled(app: &mut App, id: &str, auto: bool, message: String) {
349 if settle_compaction(app, id, auto) {
350 add_compaction_receipt(app, &message);
351 set_explicit_compaction_status(app, message, StatusToastLevel::Info, false);
352 }
353 }
354
355 /// Esc/Ctrl+C during a compact that is serving an in-flight turn must stop
356 /// the turn. Compact-only (manual `/compact` with no model request) still
357 /// cancels just the pass.
358 #[must_use]
359 pub(crate) fn compact_interrupt_should_stop_turn(app: &App) -> bool {
360 (app.is_compacting || app.manual_compaction_queued)
361 && (app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")))
362 }
363
364 /// Cancel the exact queued or running pass without cancelling an unrelated
365 /// model turn. A locally deferred request has never entered the engine, so it
366 /// can settle synchronously with no provider call; all dispatched requests
367 /// wait for the authoritative typed terminal event.
368 pub(crate) fn try_cancel_compaction(app: &mut App, engine_handle: &EngineHandle) -> bool {
369 if !app.is_compacting && !app.manual_compaction_queued {
370 return false;
371 }
372
373 if !app.is_compacting && app.deferred_manual_compaction.take().is_some() {
374 app.manual_compaction_queued = false;
375 app.manual_compaction_id = None;
376 let message = "Context compaction canceled before it started".to_string();
377 add_compaction_receipt(app, &message);
378 set_explicit_compaction_status(app, message, StatusToastLevel::Info, false);
379 return true;
380 }
381
382 let id = app
383 .active_compaction
384 .as_ref()
385 .map(|active| active.id.clone())
386 .or_else(|| app.manual_compaction_id.clone());
387 let Some(id) = id else {
388 return false;
389 };
390
391 match engine_handle.cancel_compaction(id) {
392 Ok(()) => {
393 set_explicit_compaction_status(
394 app,
395 "Canceling context compaction…".to_string(),
396 StatusToastLevel::Info,
397 false,
398 );
399 }
400 Err(error) => {
401 let message = format!("Could not cancel context compaction: {error}");
402 add_compaction_receipt(app, &message);
403 set_explicit_compaction_status(app, message, StatusToastLevel::Error, true);
404 }
405 }
406 true
407 }
408
409 #[cfg(test)]
410 pub(crate) fn maybe_warn_context_pressure(app: &mut App) {
411 let config = app.compaction_config();
412 maybe_warn_context_pressure_for_config(app, &config);
413 }
414
415 pub(crate) fn maybe_warn_context_pressure_for_config(
416 app: &mut App,
417 config: &crate::compaction::CompactionConfig,
418 ) {
419 if config.enabled {
420 app.dismiss_context_pressure_warning();
421 app.context_pressure_warning_dismissed = None;
422 return;
423 }
424 let max = config.effective_context_window.unwrap_or_else(|| {
425 crate::route_budget::route_context_window_tokens(
426 app.api_provider,
427 app.effective_model_for_budget(),
428 app.active_route_limits,
429 )
430 });
431 let Some((used, max, percent)) = context_usage_snapshot_for_window(app, max) else {
432 return;
433 };
434
435 let configured_threshold = app.auto_compact_threshold_percent.clamp(10.0, 100.0);
436 let warning_threshold = CONTEXT_SUGGEST_COMPACT_THRESHOLD_PERCENT.min(configured_threshold);
437 if percent < warning_threshold {
438 app.context_pressure_warning_dismissed = None;
439 if app.sticky_status.as_ref().is_some_and(|status| {
440 matches!(
441 status.kind,
442 crate::tui::app::StatusToastKind::ContextPressure(_)
443 )
444 }) {
445 app.clear_sticky_status();
446 }
447 app.context_pressure_warning_dismissed = None;
448 return;
449 }
450 let pressure_level = if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT {
451 crate::context_budget::PressureLevel::Critical
452 } else if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT {
453 crate::context_budget::PressureLevel::High
454 } else {
455 crate::context_budget::PressureLevel::Medium
456 };
457 if app
458 .context_pressure_warning_dismissed
459 .is_some_and(|dismissed| pressure_level <= dismissed)
460 {
461 return;
462 }
463
464 // #5239: the meter drives real budgets off this window, so an unverified
465 // one must say so next to the numbers that depend on it.
466 let window_note = if app.active_context_window_source.is_verified() {
467 ""
468 } else {
469 ", unverified window"
470 };
471
472 let recommendation = "Automatic compaction is disabled. Enable auto_compact or use /compact.";
473
474 if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT {
475 set_context_pressure_status(
476 app,
477 format!(
478 "Context critical: {percent:.0}% ({used}/{max} tokens{window_note}). {recommendation}"
479 ),
480 pressure_level,
481 );
482 return;
483 }
484
485 let status_prefix = if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT {
486 "Context high"
487 } else {
488 "Context building"
489 };
490 set_context_pressure_status(
491 app,
492 format!(
493 "{status_prefix}: {percent:.0}% ({used}/{max} tokens{window_note}). {recommendation}"
494 ),
495 pressure_level,
496 );
497 }
498
499 fn set_context_pressure_status(
500 app: &mut App,
501 text: String,
502 pressure_level: crate::context_budget::PressureLevel,
503 ) {
504 let can_replace = app.sticky_status.as_ref().is_none_or(|status| {
505 matches!(
506 status.kind,
507 crate::tui::app::StatusToastKind::ContextPressure(_)
508 )
509 });
510 if !can_replace {
511 return;
512 }
513 app.status_message = Some(text.clone());
514 app.last_status_message_seen = Some(text.clone());
515 // No TTL: this warning stays visible until compaction or explicit Esc
516 // dismissal instead of being pushed out by later transcript activity.
517 app.sticky_status = Some(crate::tui::app::StatusToast::context_pressure(
518 text,
519 pressure_level,
520 ));
521 app.needs_redraw = true;
522 }
523
524 #[cfg(test)]
525 mod config_update_tests {
526 use super::*;
527 use crate::core::engine::mock_engine_handle;
528 use crate::core::ops::Op;
529
530 #[tokio::test]
531 async fn live_compaction_update_queues_without_waiting_on_engine() {
532 let mut mock = mock_engine_handle();
533 let compaction = crate::compaction::CompactionConfig {
534 enabled: false,
535 token_threshold: 123,
536 model: "deepseek-v4-flash".to_string(),
537 effective_context_window: Some(128_000),
538 cache_summary: true,
539 focus: None,
540 runtime_cost_owner: None,
541 workspace: None,
542 image_input: crate::model_profile::SupportState::Unknown,
543 summary_instructions: None,
544 retained_user_message_tokens:
545 crate::config::DEFAULT_COMPACTION_RETAINED_USER_MESSAGE_TOKENS,
546 };
547
548 assert!(try_apply_model_and_compaction_update(
549 &mock.handle,
550 compaction.clone(),
551 AppMode::Agent,
552 None,
553 ));
554
555 assert!(matches!(
556 mock.rx_op.recv().await,
557 Some(Op::SetModel {
558 model,
559 mode: AppMode::Agent,
560 route_limits: None,
561 }) if model == compaction.model
562 ));
563 assert!(matches!(
564 mock.rx_op.recv().await,
565 Some(Op::SetCompaction { config }) if config == compaction
566 ));
567 }
568 }
569
569 lines RUST