返回 CodeWhale
frame.rs
根目录 / crates / tui / src / tui / ui / frame.rs
1 //! Frame composition: the draw entry point, the builders that assemble what a
2 //! frame needs, and streaming-text accumulation into history cells.
3 //!
4 //! Moved verbatim out of `ui.rs`.
5
6 use super::*;
7
8 /// Snapshot the posture a real `Op::SendMessage` would carry, and — when the
9 /// user supplied a hypothetical prompt — resolve the next turn's route with
10 /// the **same shared planner** dispatch uses (#1004).
11 ///
12 /// The hypothetical prompt is taken through the deterministic part of the real
13 /// submit path, in the real order: the **active skill** it would be wrapped
14 /// with, file and git mention resolution with the same error propagation, and
15 /// the paused-command note a real submit appends. That is what makes the body
16 /// the engine hashes the body a real turn would build. It is never added to
17 /// the conversation, no state is consumed, and the previewed request itself is
18 /// never sent.
19 ///
20 /// Two things a real submit does that an inspection must not, and what happens
21 /// instead:
22 ///
23 /// - **`message_submit` hooks.** They run first, before mentions, skill
24 /// wrapping, route planning, and the tool policy, and they may replace the
25 /// text or block the turn outright. Running them would give a *preview* the
26 /// side effects of a submit. So when any are configured, nothing downstream
27 /// of the text can be claimed exact and the whole manifest reports
28 /// [`crate::core::engine::preview::PreviewUnresolved::MessageSubmitHooksConfigured`] —
29 /// including under a
30 /// fixed model, because the tool policy is derived from the content too.
31 /// - **Consuming the active skill.** A real submit *takes* `app.active_skill`.
32 /// The preview clones it: the skill is still pending after an inspection,
33 /// and the previewed body is the one it would have produced. Dropping it
34 /// instead — which the first pass did — previewed an unwrapped prompt and
35 /// quietly under-reported the request by the whole skill instruction.
36 ///
37 /// Without a prompt there is no next-turn route to resolve under auto model
38 /// routing and no next-turn body under any routing, so this reports a typed
39 /// unresolved state instead of recycling the installed route.
40 pub(crate) async fn build_preview_request_inputs(
41 app: &App,
42 config: &Config,
43 engine_handle: &EngineHandle,
44 hypothetical_prompt: Option<String>,
45 ) -> crate::core::engine::preview::PreviewRequestInputs {
46 use crate::core::engine::preview::{PreviewNextTurn, PreviewRequestInputs, PreviewUnresolved};
47
48 let requested_model = if app.auto_model {
49 "auto".to_string()
50 } else {
51 app.model.clone()
52 };
53 let prompt_supplied = hypothetical_prompt.is_some();
54 let posture = |next_turn, unresolved| PreviewRequestInputs {
55 mode: app.mode,
56 allow_shell: app.allow_shell,
57 trust_mode: app.trust_mode,
58 auto_approve: app_auto_approve_enabled(app),
59 approval_mode: app.approval_mode,
60 allowed_tools: app.active_allowed_tools.clone(),
61 dynamic_tools: Vec::new(),
62 provenance: crate::core::ops::UserInputProvenance::ExternalUser,
63 requested_model: requested_model.clone(),
64 requested_reasoning: app.reasoning_effort.as_setting().to_string(),
65 auto_model: app.auto_model,
66 hypothetical_prompt_supplied: prompt_supplied,
67 next_turn,
68 unresolved,
69 };
70
71 let Some(prompt) = hypothetical_prompt else {
72 // Never clear the unresolved flag just because a session has a route:
73 // under auto routing the next prompt is what decides it.
74 return posture(
75 None,
76 if app.auto_model {
77 PreviewUnresolved::AutoRouteNeedsPrompt
78 } else {
79 PreviewUnresolved::NoPrompt
80 },
81 );
82 };
83
84 // Auto routing runs a model classifier. `/preview-request` is an offline
85 // inspection command, so it stops before prompt resolution or the shared
86 // planner can reach that call. Production remains responsible for Auto.
87 if auto_router::should_resolve_auto_model_selection(app) {
88 return posture(None, PreviewUnresolved::AutoRouteClassificationNotExecuted);
89 }
90
91 if app
92 .hooks
93 .has_hooks_for_event(crate::hooks::HookEvent::MessageSubmit)
94 {
95 return posture(None, PreviewUnresolved::MessageSubmitHooksConfigured);
96 }
97
98 // Clone, never `take`: an inspection may not consume the pending skill.
99 let message = QueuedMessage {
100 display: prompt.clone(),
101 skill_instruction: app.active_skill.clone(),
102 skill_provenance: app.active_skill_provenance.clone(),
103 };
104 let mut git_cache = crate::tui::git_mention::GitMentionCache::default();
105 // Same failure surface as a real submit: a plugin-skill authority mismatch
106 // aborts the turn there and must not be papered over with the raw prompt
107 // here — that would describe a request the user could not send.
108 let mut content = match queued_message_content_for_app(
109 app,
110 &message,
111 std::env::current_dir().ok(),
112 &mut git_cache,
113 ) {
114 Ok(content) => content,
115 Err(error) => {
116 return posture(
117 None,
118 PreviewUnresolved::PromptResolutionFailed(error.to_string()),
119 );
120 }
121 };
122 // A real submit appends the paused-command note before planning the route.
123 // `plan_paused_command_message` is pure — it decides, it does not resume or
124 // discard anything — so the preview can use the same value.
125 let paused_dispatch = plan_paused_command_message(app, &prompt);
126 if let Some(note) = paused_dispatch.note() {
127 content.push_str(note);
128 }
129
130 let (app_route_identity, route_config) = app_scoped_runtime_config(app, config);
131 let planned = plan_turn_route(TurnRoutePlanRequest {
132 route_config: &route_config,
133 app_route_identity: &app_route_identity,
134 api_provider: app.api_provider,
135 app_model: &app.model,
136 auto_model: app.auto_model,
137 reasoning_effort: app.reasoning_effort,
138 mode: app.mode,
139 content: &content,
140 display_text: &prompt,
141 auto_router_context: &auto_router::recent_auto_router_context(&app.api_messages),
142 should_auto_resolve: false,
143 allow_auto_router_response_cache: false,
144 preflight_required: engine_handle.client_preflight_required(),
145 auto_compact_user_configured: app.auto_compact_user_configured,
146 auto_compact: app.auto_compact,
147 auto_compact_threshold_percent: app.auto_compact_threshold_percent,
148 })
149 .await;
150
151 match planned {
152 Ok(planned) => {
153 let prompt_context = crate::core::engine::NextTurnPromptContext::for_planned_turn(
154 planned.route.identity.provider,
155 planned.route.model.clone(),
156 crate::route_budget::known_route_limits(planned.route.candidate.limits()),
157 app.mode,
158 paused_dispatch.goal_objective(app),
159 app.hunt.verdict.goal_status(),
160 app.hunt.token_budget,
161 app.translation_enabled,
162 app.verbosity.clone(),
163 );
164 posture(
165 Some(Box::new(PreviewNextTurn {
166 content,
167 route: Box::new(planned.route),
168 prompt_context,
169 reasoning_effort: planned.effective_reasoning_effort,
170 reasoning_effort_auto: planned.auto_controls_reasoning,
171 auto_route_source: planned
172 .auto_selection
173 .as_ref()
174 .map(|selection| selection.source.label().to_string()),
175 routing_source: planned.routing_source,
176 compaction: planned.compaction,
177 })),
178 PreviewUnresolved::NoPrompt,
179 )
180 }
181 Err(error) => posture(None, PreviewUnresolved::PlanFailed(error)),
182 }
183 }
184
185 pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
186 let provider = app.api_provider;
187 let max_subagents = app.max_subagents.clamp(1, crate::config::MAX_SUBAGENTS);
188 EngineConfig {
189 model: app.model.clone(),
190 active_route_limits: app.active_route_limits,
191 workspace: app.workspace.clone(),
192 allow_shell: app.allow_shell,
193 trust_mode: app.trust_mode,
194 notes_path: config.notes_path(),
195 mcp_config_path: config.mcp_config_path(),
196 skills_dir: app.skills_dir.clone(),
197 skills_scan_codewhale_only: app.skills_scan_codewhale_only,
198 plugin_registry: Some(std::sync::Arc::clone(&app.plugin_registry)),
199 instructions: configured_instruction_sources(config),
200 project_context_pack_enabled: config.project_context_pack_enabled(),
201 translation_enabled: app.translation_enabled,
202 verbosity: app.verbosity.clone(),
203 // Effectively unlimited: the previous cap of 100 hit the ceiling on
204 // long multi-step plans (wide refactors, sub-agent orchestration) and
205 // presented as the agent "giving up mid-task". `u32::MAX` is the type
206 // ceiling; users can still interrupt with Ctrl+C / Esc, and a turn
207 // naturally ends when the model stops emitting tool calls. A real
208 // runaway is rare and human-noticeable; we trust the operator.
209 max_steps: u32::MAX,
210 max_subagents,
211 max_admitted_subagents: config
212 .max_admitted_subagents_for_provider(provider)
213 .max(max_subagents),
214 launch_concurrency: config
215 .launch_concurrency_for_provider(provider)
216 .max(app.mode.mode_delegation_launch_floor()),
217 subagents_enabled: config.subagents_enabled_for_provider(provider),
218 features: config.features(),
219 auto_review_policy: config.auto_review_policy(),
220 compaction: app.compaction_config(),
221 todos: app.todos.clone(),
222 plan_state: app.plan_state.clone(),
223 goal_state: crate::tools::goal::new_shared_goal_state_from_host_status(
224 app.hunt.quarry.clone(),
225 app.hunt.token_budget,
226 app.hunt.verdict.goal_status(),
227 ),
228 max_spawn_depth: config.subagent_max_spawn_depth_for_provider(provider),
229 subagent_token_budget: config.subagent_token_budget_for_provider(provider),
230 allowed_tools: app.active_allowed_tools.clone(),
231 disallowed_tools: None,
232 max_tool_calls: None,
233 hook_executor: app.runtime_services.hook_executor.clone(),
234 network_policy: config.network.clone().map(|toml_cfg| {
235 crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
236 }),
237 snapshots_enabled: config.snapshots_config().enabled,
238 snapshots_max_workspace_bytes: config
239 .snapshots_config()
240 .max_workspace_gb
241 .saturating_mul(1024 * 1024 * 1024),
242 lsp_config: config
243 .lsp
244 .clone()
245 .map(crate::config::LspConfigToml::into_runtime),
246 runtime_services: app.runtime_services.clone(),
247 subagent_model_overrides: config.subagent_model_overrides(),
248 fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load(
249 &config.fleet_config(),
250 &app.workspace,
251 )),
252 subagent_api_timeout: Duration::from_secs(
253 config.subagent_api_timeout_secs_for_provider(provider),
254 ),
255 stream_chunk_timeout: Duration::from_secs(app.stream_chunk_timeout_secs),
256 subagent_heartbeat_timeout: Duration::from_secs(
257 config.subagent_heartbeat_timeout_secs_for_provider(provider),
258 ),
259 prefer_bwrap: config.prefer_bwrap.unwrap_or(false),
260 memory_enabled: config.memory_enabled(),
261 memory_path: config.memory_path(),
262 speech_output_dir: config.speech_output_dir(),
263 vision_config: config.vision_model_config(),
264 strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
265 goal_objective: app.hunt.quarry.clone(),
266 goal_token_budget: app.hunt.token_budget,
267 goal_status: app.hunt.verdict.goal_status(),
268 goal_max_continuations: config.goal_max_continuations(),
269 locale_tag: app.ui_locale.tag().to_string(),
270 workshop: config.workshop.clone(),
271 search_provider: config.search_provider(),
272 search_api_key: config.search.as_ref().and_then(|s| s.api_key.clone()),
273 search_base_url: config.search.as_ref().and_then(|s| s.base_url.clone()),
274 tools_always_load: config.tools_always_load(),
275 tools: config.tools.clone(),
276 workspace_follow_symlinks: app.workspace_follow_symlinks,
277 exec_policy_engine: config.exec_policy_engine.clone(),
278 terminal_chrome_enabled: true,
279 advisor_config: config
280 .advisor
281 .as_ref()
282 .map(crate::tools::subagent::AdvisorConfig::from_toml)
283 .unwrap_or_else(crate::tools::subagent::AdvisorConfig::disabled),
284 }
285 }
286
287 #[cfg(test)]
288 pub(crate) fn build_app_system_prompt(app: &App, config: &Config) -> SystemPrompt {
289 build_app_system_prompt_with_goal(app, config, app.hunt.quarry.as_deref())
290 }
291
292 pub(crate) fn build_app_system_prompt_with_goal(
293 app: &App,
294 config: &Config,
295 goal_objective: Option<&str>,
296 ) -> SystemPrompt {
297 let instructions = configured_instruction_sources(config);
298 let user_memory_block = crate::native_memory::native_prompt_block(
299 config.memory_enabled(),
300 &config.memory_path(),
301 &app.workspace,
302 );
303 prompts::system_prompt_for_mode_with_context_skills_and_session(
304 &app.workspace,
305 None,
306 Some(&app.skills_dir),
307 Some(&instructions),
308 prompts::PromptSessionContext {
309 user_memory_block: user_memory_block.as_deref(),
310 goal_objective,
311 project_context_pack_enabled: config.project_context_pack_enabled(),
312 locale_tag: app.ui_locale.tag(),
313 translation_enabled: app.translation_enabled,
314 model_id: &app.model,
315 context_window_override: Some(crate::route_budget::route_context_window_tokens(
316 app.api_provider,
317 &app.model,
318 app.active_route_limits,
319 )),
320 verbosity: app.verbosity.as_deref(),
321 skills_scan_codewhale_only: app.skills_scan_codewhale_only,
322 plugin_registry: Some(app.plugin_registry.as_ref()),
323 mode: app.mode,
324 },
325 )
326 }
327
328 pub(crate) fn build_session_snapshot(
329 app: &mut App,
330 manager: &SessionManager,
331 ) -> Result<SavedSession, String> {
332 let model = app.model_selection_for_persistence();
333 let work_state = match app.try_work_state_snapshot() {
334 Ok(work_state) => work_state,
335 Err(err) => app.last_known_work_state.clone().ok_or_else(|| {
336 format!("automatic session snapshot skipped while Work state is busy: {err}")
337 })?,
338 };
339 let mut session = if let Some(existing_id) = app.current_session_id.as_ref() {
340 create_saved_session_with_id_and_mode(
341 existing_id.clone(),
342 &app.api_messages,
343 &model,
344 &app.workspace,
345 u64::from(app.session.total_tokens),
346 app.system_prompt.as_ref(),
347 Some(app.mode.as_setting()),
348 )
349 } else {
350 create_saved_session_with_mode(
351 &app.api_messages,
352 &model,
353 &app.workspace,
354 u64::from(app.session.total_tokens),
355 app.system_prompt.as_ref(),
356 Some(app.mode.as_setting()),
357 )
358 };
359 if let Some(cached) = app
360 .current_session_metadata
361 .as_ref()
362 .filter(|cached| cached.id == session.metadata.id)
363 {
364 session.metadata.created_at = cached.created_at;
365 session.metadata.title.clone_from(&cached.title);
366 session
367 .metadata
368 .parent_session_id
369 .clone_from(&cached.parent_session_id);
370 session.metadata.forked_from_message_count = cached.forked_from_message_count;
371 session.metadata.archived = cached.archived;
372 }
373 // The cache above is a hint; disk is the authority for lifecycle state.
374 // Re-reading here is what makes "an archive or rename cannot be reverted
375 // by autosave" true regardless of which surface applied it or when
376 // (#2934 / #4397). One bounded metadata-prefix read, not a transcript scan.
377 let _ = manager.merge_persisted_lifecycle(&mut session.metadata);
378 if let Some(cached) = app.current_session_metadata.as_mut()
379 && cached.id == session.metadata.id
380 {
381 cached.title.clone_from(&session.metadata.title);
382 cached.archived = session.metadata.archived;
383 }
384 session
385 .metadata
386 .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
387 app.sync_cost_to_metadata(&mut session.metadata);
388 session.context_references = app.session_context_references.clone();
389 session.artifacts = app.session_artifacts.clone();
390 session.work_state = work_state;
391 session.last_auto_route = app.auto_route_for_persistence();
392 app.current_session_metadata = Some(session.metadata.clone());
393 // Claim ownership of this session for the process. From here on the
394 // Runtime API refuses external renames/archives of it with a typed 409
395 // rather than writing something the next snapshot would revert.
396 //
397 // Claiming here rather than at each of the ten `current_session_id`
398 // assignment sites is deliberate: this is the function that establishes
399 // "the TUI holds the authoritative copy", which is exactly the condition
400 // the conflict protects. A session that has never been snapshotted has no
401 // in-memory state to lose, so leaving it unclaimed is correct, not a gap.
402 crate::session_manager::set_live_session(Some(&session.metadata.id));
403 Ok(session)
404 }
405
406 pub(crate) fn tool_cell_is_running(tool: &ToolCell) -> bool {
407 match tool {
408 ToolCell::Exec(cell) => cell.status == ToolStatus::Running,
409 ToolCell::Exploring(cell) => cell
410 .entries
411 .iter()
412 .any(|entry| entry.status == ToolStatus::Running),
413 ToolCell::PlanUpdate(cell) => cell.status == ToolStatus::Running,
414 ToolCell::PatchSummary(cell) => cell.status == ToolStatus::Running,
415 ToolCell::Review(cell) => cell.status == ToolStatus::Running,
416 ToolCell::DiffPreview(_) => false,
417 ToolCell::Mcp(cell) => cell.status == ToolStatus::Running,
418 ToolCell::ViewImage(_) => false,
419 ToolCell::WebSearch(cell) => cell.status == ToolStatus::Running,
420 ToolCell::Generic(cell) => cell.status == ToolStatus::Running,
421 }
422 }
423
424 /// Strip ANSI control codes / non-printable bytes from a streaming
425 /// text chunk. `pub(super)` because `tui::notifications` consumes it
426 /// from `crate::tui::ui` for its per-turn message composition.
427 pub(crate) fn sanitize_stream_chunk(chunk: &str) -> String {
428 // Keep printable characters and common whitespace; drop control bytes.
429 chunk
430 .chars()
431 .filter(|c| *c == '\n' || *c == '\t' || !c.is_control())
432 .collect()
433 }
434
435 /// Ensure an in-flight streaming Assistant cell exists in history and return
436 /// its index. Thinking cells go through `streaming_thinking::ensure_active_entry`
437 /// (active cell) instead.
438 pub(crate) fn ensure_streaming_assistant_history_cell(app: &mut App) -> usize {
439 if let Some(index) = app.streaming_message_index {
440 return index;
441 }
442 app.add_message(HistoryCell::Assistant {
443 content: String::new(),
444 streaming: true,
445 });
446 let index = app.history.len().saturating_sub(1);
447 app.streaming_message_index = Some(index);
448 index
449 }
450
451 pub(crate) fn append_streaming_text(app: &mut App, index: usize, text: &str) {
452 if text.is_empty() {
453 return;
454 }
455 app.resync_history_revisions();
456 let Some(previous_revision) = app.history_revisions.get(index).copied() else {
457 return;
458 };
459 let chained_from_revision = app
460 .streaming_source_receipt
461 .filter(|receipt| receipt.cell_index == index && receipt.to_revision == previous_revision)
462 .map_or(previous_revision, |receipt| receipt.from_revision);
463 let mut content_len = None;
464 if let Some(HistoryCell::Assistant { content, .. }) = app.history.get_mut(index) {
465 content.push_str(text);
466 content_len = Some(content.len());
467 // Bump only the streaming cell's per-cell revision so the transcript
468 // cache re-renders just this cell. Without this, the cache would
469 // either skip the update entirely (now that the global
470 // history_version is no longer fanned out across every cell) or fall
471 // back to a full re-wrap of the entire transcript every chunk.
472 app.bump_history_cell(index);
473 }
474 let Some(content_len) = content_len else {
475 return;
476 };
477 if let Some(to_revision) = app.history_revisions.get(index).copied() {
478 app.streaming_source_receipt = Some(crate::tui::transcript::StreamingSourceReceipt {
479 cell_index: index,
480 from_revision: chained_from_revision,
481 to_revision,
482 content_len,
483 });
484 }
485 }
486
487 pub(crate) fn accrue_streaming_token_estimate(app: &mut App, visible_text: &str) {
488 if visible_text.is_empty() {
489 return;
490 }
491 app.streaming_output_token_estimate = app
492 .streaming_output_token_estimate
493 .saturating_add(estimate_output_tokens_from_text(visible_text));
494 }
495
496 pub(crate) fn commit_streaming_display_tick(
497 app: &mut App,
498 stream_display_clock: &mut StreamDisplayClock,
499 now: Instant,
500 ) -> bool {
501 if !stream_display_clock.take_due(now) {
502 return false;
503 }
504
505 let mut updated = false;
506 if let Some(index) = app.streaming_message_index {
507 let committed = app.streaming_state.commit_text(0);
508 if !committed.is_empty() {
509 append_streaming_text(app, index, &committed);
510 accrue_streaming_token_estimate(app, &committed);
511 updated = true;
512 }
513 } else if let Some(entry_idx) = app.streaming_thinking_active_entry {
514 let committed = app.streaming_state.commit_text(0);
515 if !committed.is_empty() {
516 if app.translation_enabled {
517 streaming_thinking::set_placeholder(app, entry_idx);
518 } else {
519 streaming_thinking::append(app, entry_idx, &committed);
520 }
521 updated = true;
522 }
523 }
524
525 if app.streaming_state.has_pending_stream_text(0) {
526 stream_display_clock.note_delta(now);
527 }
528
529 updated
530 }
531
532 pub(crate) fn live_tool_receipt_messages(
533 app: &App,
534 id: &str,
535 raw: &str,
536 success: bool,
537 ) -> Vec<Message> {
538 let mut messages = Vec::with_capacity(2);
539 if let Some(tool_use_msg) = app.api_messages.iter().rev().find(|message| {
540 message.content.iter().any(|block| {
541 matches!(block, ContentBlock::ToolUse { id: tool_use_id, .. } if tool_use_id == id)
542 })
543 }) {
544 messages.push(tool_use_msg.clone());
545 }
546 messages.push(Message {
547 role: "user".to_string(),
548 content: vec![ContentBlock::ToolResult {
549 tool_use_id: id.to_string(),
550 content: raw.to_string(),
551 is_error: Some(!success),
552 content_blocks: None,
553 }],
554 });
555 messages
556 }
557
558 pub(crate) fn compact_live_tool_receipt(
559 messages: Vec<Message>,
560 artifacts: Vec<crate::artifacts::ArtifactRecord>,
561 raw: String,
562 ) -> Option<String> {
563 let (compacted, _) =
564 crate::tool_output_receipts::compact_messages_for_persistence(&messages, &artifacts);
565 let content = compacted
566 .last()
567 .and_then(|message| message.content.first())
568 .and_then(|block| match block {
569 ContentBlock::ToolResult { content, .. } => Some(content),
570 _ => None,
571 })?;
572 if content != &raw && live_tool_content_is_receipt(content) {
573 Some(content.clone())
574 } else {
575 None
576 }
577 }
578
579 pub(crate) fn live_tool_content_is_receipt(content: &str) -> bool {
580 content.trim_start().starts_with("[TOOL_OUTPUT_RECEIPT]")
581 }
582
583 /// Build the pending-input preview widget from current `App` state.
584 ///
585 /// v0.6.6 (#122) wires all three buckets:
586 /// - `pending_steers` — typed during a running turn + Esc; held until the
587 /// abort lands and gets resubmitted as a fresh merged turn.
588 /// - `rejected_steers` — engine declined a mid-turn steer (scaffolding;
589 /// no engine path produces these yet but the bucket renders with a distinct
590 /// rejected-steer label).
591 /// - `queued_messages` — Enter while busy; drained at end-of-turn. In Operate,
592 /// the foreground operator dispatches these as additional background tasks.
593 pub(crate) fn build_pending_input_preview(app: &App) -> PendingInputPreview {
594 let mut preview = PendingInputPreview::new();
595 let selected_attachment = app.selected_composer_attachment_index();
596 let mut attachment_index = 0usize;
597 preview.context_items = crate::tui::file_mention::pending_context_previews(&app.input)
598 .into_iter()
599 .map(|item| {
600 let selected = if item.removable {
601 let selected = selected_attachment == Some(attachment_index);
602 attachment_index += 1;
603 selected
604 } else {
605 false
606 };
607 ContextPreviewItem {
608 kind: item.kind,
609 label: item.label,
610 detail: item.detail,
611 included: item.included,
612 removable: item.removable,
613 selected,
614 }
615 })
616 .collect();
617 preview.pending_steers = app
618 .pending_steers
619 .iter()
620 .map(|m| m.display.clone())
621 .collect();
622 preview.rejected_steers = app.rejected_steers.iter().cloned().collect();
623 preview.queued_messages = app
624 .queued_messages
625 .iter()
626 .map(|m| m.display.clone())
627 .collect();
628 preview.editing_queued_message = app.queued_draft.as_ref().map(|draft| {
629 if app.input.trim().is_empty() {
630 draft.display.clone()
631 } else {
632 app.input.clone()
633 }
634 });
635 preview
636 }
637
638 pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) {
639 let size = f.area();
640 // Keep the view stack's focus-context texture prototype (#4823) in step
641 // with the parsed setting each frame: a plain enum/theme copy, no
642 // allocation. `Off` leaves the render byte-identical to before.
643 app.view_stack
644 .set_focus_texture(app.focus_texture, app.ui_theme);
645 app.sidebar_hover = crate::tui::app::SidebarHoverState::default();
646 app.viewport.last_approval_area = None;
647 // Keep the OSC-0 whale title truthful to the current shell phase so
648 // alt-tabbed sessions communicate state without a second in-app spinner.
649 crate::tui::underwater::sync_title_activity(app);
650
651 // Clear entire area with the configured app background.
652 let background = Block::default().style(Style::default().bg(app.ui_theme.surface_bg));
653 f.render_widget(background, size);
654
655 // Show onboarding screen if needed
656 if app.onboarding != OnboardingState::None {
657 onboarding::render(f, size, app);
658 // The provider step hosts the canonical setup picker as a modal on
659 // top of the onboarding backdrop; without this the pushed view is
660 // invisible and recovery appears to hang on an empty legacy screen.
661 if app.onboarding == OnboardingState::Provider && !app.view_stack.is_empty() {
662 let buf = f.buffer_mut();
663 app.view_stack.render(size, buf);
664 }
665 return;
666 }
667
668 if app.launch.visible {
669 crate::tui::underwater::render_launch_screen(size, f.buffer_mut(), app);
670 crate::tui::underwater::record_launch_row_areas(size, &mut app.launch);
671 if !app.view_stack.is_empty() {
672 if app.view_stack.top_kind() == Some(ModalKind::Approval) {
673 app.viewport.last_approval_area = app.view_stack.top_occupied_region(size);
674 }
675 let buf = f.buffer_mut();
676 app.view_stack.render(size, buf);
677 }
678 return;
679 }
680
681 let header_height = header_height_for(size.height);
682 let footer_height = crate::tui::phase_strip::height();
683 let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
684 let mention_menu_limit = app.mention_menu_limit;
685 let mention_menu_entries =
686 crate::tui::file_mention::visible_mention_menu_entries(app, mention_menu_limit);
687 if !mention_menu_entries.is_empty() && app.mention_menu_selected >= mention_menu_entries.len() {
688 app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
689 }
690 // Evaluate the fully-idle predicate exactly once per frame. It decides
691 // both how many rows the rail may reserve (here) and whether the idle
692 // ocean draws its brand mark (in ChatWidget); calling it twice would let
693 // the reservation and the render disagree inside a single frame.
694 let idle_empty = crate::tui::widgets::should_render_empty_state(app);
695 let rail_budget = rail_row_budget(app, size.width, size.height, idle_empty);
696 let top_work_strip_height =
697 crate::tui::work_surface::height(app, size.width, size.height, rail_budget);
698
699 // Defensive two-pass layout: pin the header to the absolute top row,
700 // then split the remaining body area for chat / preview / composer /
701 // footer. This guarantees the header is never vertically centered
702 // regardless of ratatui Flex defaults or terminal size.
703 // Fixes #1834 — macOS terminal title centering.
704 let (header_area, body_area) = {
705 let split = Layout::default()
706 .direction(Direction::Vertical)
707 .flex(ratatui::layout::Flex::Start)
708 .constraints([Constraint::Length(header_height), Constraint::Min(1)])
709 .split(size);
710 (split[0], split[1])
711 };
712
713 let body_height = body_area.height;
714 let composer_max_height = body_height
715 .saturating_sub(MIN_CHAT_HEIGHT + footer_height + top_work_strip_height)
716 .max(MIN_COMPOSER_HEIGHT);
717 let composer_height = {
718 let composer_widget = ComposerWidget::new(
719 app,
720 composer_max_height,
721 &slash_menu_entries,
722 &mention_menu_entries,
723 );
724 composer_widget.desired_height(size.width)
725 };
726
727 // Pending-input preview (queued / steered messages). Empty when nothing's
728 // queued, so zero height when idle. Phase 2 of #85 — solves the
729 // "messages typed during a running turn vanish" complaint by giving the
730 // user immediate visible feedback above the composer.
731 let pending_preview = build_pending_input_preview(app);
732 let desired_preview_height = pending_preview.desired_height(size.width);
733
734 // WorkflowPanel unified activity surface (#4121). Expanded while running
735 // (interactive drill-in above the composer); when collapsed the panel
736 // takes no rows — its persistent status lives in the top status bar as a
737 // header chip instead (#5040). Zero height when no panel.
738 let desired_workflow_panel_height = app
739 .workflow_panel
740 .as_ref()
741 .filter(|panel| panel.expanded)
742 .map(|panel| panel.desired_height(size.width))
743 .unwrap_or(0);
744 let auxiliary_budget = body_height.saturating_sub(
745 top_work_strip_height
746 .saturating_add(MIN_CHAT_HEIGHT)
747 .saturating_add(composer_height)
748 .saturating_add(footer_height),
749 );
750 // Queued-only previews author the direct controls in row two (and fall
751 // back to controls-only when just one row remains). Mixed previews retain
752 // up to three compact rows at the release floor.
753 let preview_cap = if size.height >= 20 { 4 } else { 3 };
754 let preview_height = desired_preview_height.min(auxiliary_budget.min(preview_cap));
755 let workflow_panel_height =
756 desired_workflow_panel_height.min(auxiliary_budget.saturating_sub(preview_height));
757
758 // Ocean live phases put the phase strip above the composer so activity
759 // stays attached to the transcript and the prompt is the final bottom
760 // object. Idle/typing keep a quiet phase under the prompt.
761 let phase = crate::tui::underwater::ShellPhase::from_app(app);
762 let phase_above =
763 crate::tui::phase_strip::PhaseStripPlacement::for_phase(phase).is_above_composer();
764 let (composer_slot, footer_slot, tail_constraints) = if phase_above {
765 (
766 5,
767 4,
768 [
769 Constraint::Length(footer_height),
770 Constraint::Length(composer_height),
771 ],
772 )
773 } else {
774 (
775 4,
776 5,
777 [
778 Constraint::Length(composer_height),
779 Constraint::Length(footer_height),
780 ],
781 )
782 };
783
784 let body_chunks = Layout::default()
785 .direction(Direction::Vertical)
786 .flex(ratatui::layout::Flex::Start)
787 .constraints([
788 Constraint::Length(top_work_strip_height), // Tasks + To-do above transcript
789 Constraint::Min(1), // Chat area
790 Constraint::Length(workflow_panel_height), // Workflow panel (#4121)
791 Constraint::Length(preview_height), // Pending input preview (0 if empty)
792 tail_constraints[0],
793 tail_constraints[1],
794 ])
795 .split(body_area);
796
797 let (work_chat_area, side_work_area) =
798 crate::tui::work_surface::split_chat(app, body_chunks[1], rail_min_chat_width(idle_empty));
799
800 if top_work_strip_height > 0 {
801 crate::tui::work_surface::render(f, body_chunks[0], app);
802 } else if let Some(work_area) = side_work_area {
803 crate::tui::work_surface::render(f, work_area, app);
804 }
805
806 crate::tui::underwater::render_header(header_area, f.buffer_mut(), app);
807
808 // Render the transcript and optional file-tree sidecar. The underwater
809 // default deliberately has no legacy right sidebar: Tasks and To-do own
810 // the strip above, Fleet owns `/fleet`, and dense context owns its
811 // inspector. Keeping the sidebar here was the architectural reason the
812 // rejected build still read as the old TUI under a gradient.
813 let shell_ocean;
814 {
815 // Defensive backstop (#400): fill the entire body area with ink
816 // background before any sub-widgets render, so cells that end up
817 // uncovered by layout splits (e.g. after file-tree toggle or
818 // resize) don't retain stale content from a previous frame.
819 Block::default()
820 .style(Style::default().bg(app.ui_theme.surface_bg))
821 .render(work_chat_area, f.buffer_mut());
822
823 // When the file-tree pane is visible and the terminal is wide
824 // enough, reserve the left ~25% for the file tree.
825 let chat_area =
826 if app.file_tree.is_some() && work_chat_area.width >= FILE_TREE_MIN_HOST_WIDTH {
827 app.file_tree_visible = true;
828 let split = Layout::default()
829 .direction(Direction::Horizontal)
830 .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
831 .split(work_chat_area);
832 let tree_area = split[0];
833 let remaining = split[1];
834
835 // Render the file-tree pane.
836 if let Some(ref mut state) = app.file_tree {
837 crate::tui::file_tree::render_file_tree(f, tree_area, state, app.ui_theme.mode);
838 }
839
840 remaining
841 } else {
842 app.file_tree_visible = false;
843 work_chat_area
844 };
845 app.sidebar_hover_tooltip = None;
846
847 let chat_widget = ChatWidget::new(app, chat_area).with_ocean_viewport(size);
848 shell_ocean = chat_widget.ocean_column();
849 let buf = f.buffer_mut();
850 chat_widget.render(chat_area, buf);
851 }
852
853 // Workflow panel between chat and pending-input preview (#4121).
854 if workflow_panel_height > 0 {
855 if let Some(panel) = app.workflow_panel.as_ref() {
856 let area = body_chunks[2];
857 app.viewport.last_workflow_panel_area = Some(area);
858 app.viewport.last_workflow_cancel_area =
859 panel.cancel_hint_span(area.width).map(|(start, end)| Rect {
860 x: area.x.saturating_add(start),
861 y: area.y,
862 width: end.saturating_sub(start),
863 height: 1,
864 });
865 let buf = f.buffer_mut();
866 panel.render(area, buf);
867 }
868 } else {
869 app.viewport.last_workflow_panel_area = None;
870 app.viewport.last_workflow_cancel_area = None;
871 }
872
873 // Render pending-input preview (queued/steered messages, if any).
874 if preview_height > 0 {
875 let buf = f.buffer_mut();
876 pending_preview.render(body_chunks[3], buf);
877 }
878
879 // Render composer
880 let cursor_pos = {
881 let composer_widget = ComposerWidget::new(
882 app,
883 composer_max_height,
884 &slash_menu_entries,
885 &mention_menu_entries,
886 );
887 let buf = f.buffer_mut();
888 composer_widget.render(body_chunks[composer_slot], buf);
889 composer_widget.cursor_pos(body_chunks[composer_slot])
890 };
891 app.viewport.last_composer_area = Some(body_chunks[composer_slot]);
892 {
893 let area = body_chunks[composer_slot];
894 let composer_widget = ComposerWidget::new(
895 app,
896 composer_max_height,
897 &slash_menu_entries,
898 &mention_menu_entries,
899 );
900 let inner = if composer_widget.has_panel(area) {
901 ratatui::widgets::Block::default()
902 .borders(ratatui::widgets::Borders::TOP | ratatui::widgets::Borders::BOTTOM)
903 .inner(area)
904 } else if area.height >= 2 {
905 ratatui::widgets::Block::default()
906 .borders(ratatui::widgets::Borders::TOP)
907 .inner(area)
908 } else {
909 area
910 };
911 app.viewport.last_composer_content = Some(inner);
912
913 // Compute scroll offset and top padding for mouse coordinate mapping.
914 let input_text = app.composer_display_input();
915 let input_cursor = app.composer_display_cursor();
916 let content_geometry =
917 crate::tui::widgets::composer_content_geometry(inner, app.is_history_search_active());
918 let content_width = content_geometry.text_width();
919 let menu_lines = ComposerWidget::new(
920 app,
921 composer_max_height,
922 &slash_menu_entries,
923 &mention_menu_entries,
924 )
925 .active_menu_reserved_rows();
926 let budget = crate::tui::widgets::composer_input_rows_budget(inner.height, menu_lines);
927 let (_, _, _, scroll_offset) = crate::tui::widgets::layout_input_with_scroll(
928 input_text,
929 input_cursor,
930 content_width,
931 budget,
932 );
933 let visual_rows = if input_text.is_empty() {
934 let hint: Option<std::borrow::Cow<'_, str>> = if let Some(ref suggestion) =
935 app.prompt_suggestion
936 && !app.is_history_search_active()
937 {
938 Some(std::borrow::Cow::Borrowed(suggestion.as_str()))
939 } else {
940 Some(crate::tui::widgets::composer_empty_hint_text(app))
941 };
942 crate::tui::widgets::empty_composer_visual_rows(hint.as_deref(), content_width, budget)
943 } else {
944 // Count wrapped lines (approximation matching the render path).
945 crate::tui::widgets::wrap_input_lines_for_mouse(input_text, content_width).len()
946 };
947 let top_padding = budget.saturating_sub(visual_rows.clamp(1, budget));
948 app.viewport.last_composer_scroll_offset = scroll_offset;
949 app.viewport.last_composer_top_padding = top_padding;
950 }
951 if let Some(cursor_pos) = cursor_pos {
952 f.set_cursor_position(cursor_pos);
953 }
954
955 crate::tui::underwater::render_footer(body_chunks[footer_slot], f.buffer_mut(), app);
956
957 // The underwater shell is one water column, not a stack of independently
958 // shaded panels. Continue the transcript's absolute-row ramp through each
959 // ordinary shell surface after its foreground has rendered. Semantic
960 // backgrounds such as selection, hover, errors, and code blocks do not
961 // match these base colors and therefore remain intact.
962 if let Some(column) = shell_ocean {
963 column.paint_matching(header_area, f.buffer_mut(), app.ui_theme.header_bg);
964 if top_work_strip_height > 0 {
965 column.paint_matching(body_chunks[0], f.buffer_mut(), app.ui_theme.surface_bg);
966 }
967 if let Some(side_area) = side_work_area {
968 column.paint_matching(side_area, f.buffer_mut(), app.ui_theme.surface_bg);
969 }
970 column.paint_matching(work_chat_area, f.buffer_mut(), app.ui_theme.surface_bg);
971 column.paint_matching(body_chunks[2], f.buffer_mut(), app.ui_theme.surface_bg);
972 column.paint_matching(body_chunks[3], f.buffer_mut(), app.ui_theme.surface_bg);
973 column.paint_matching(
974 body_chunks[composer_slot],
975 f.buffer_mut(),
976 app.ui_theme.composer_bg,
977 );
978 column.paint_matching(
979 body_chunks[footer_slot],
980 f.buffer_mut(),
981 app.ui_theme.footer_bg,
982 );
983 }
984 // Decision card overlay (v0.8.43 truth-surface). When a decision card is
985 // active, render it centered on top of the transcript.
986 if let Some(ref card) = app.decision_card {
987 let card_width = size.width.clamp(30, 60);
988 let card_height = card.desired_height(card_width);
989 let card_area = ratatui::layout::Rect {
990 x: size
991 .x
992 .saturating_add(size.width.saturating_sub(card_width) / 2),
993 y: size
994 .y
995 .saturating_add(size.height.saturating_sub(card_height) / 2),
996 width: card_width,
997 height: card_height.min(size.height),
998 };
999 let buf = f.buffer_mut();
1000 card.render(card_area, buf);
1001 }
1002
1003 if !app.view_stack.is_empty() {
1004 // The live transcript overlay snapshots the app's history + active
1005 // cell on each render so streaming mutations propagate. Other views
1006 // are static and skip this refresh.
1007 if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
1008 refresh_live_transcript_overlay(app);
1009 } else if app.view_stack.top_kind() == Some(ModalKind::ContextInspector) {
1010 refresh_context_inspector_overlay(app);
1011 }
1012 if app.view_stack.top_kind() == Some(ModalKind::Approval) {
1013 app.viewport.last_approval_area = app.view_stack.top_occupied_region(size);
1014 }
1015 let buf = f.buffer_mut();
1016 app.view_stack.render(size, buf);
1017 }
1018 }
1019
1020 /// Draw a complete application frame, optionally with a full viewport reset.
1021 ///
1022 /// When `full_repaint` is true, the terminal scroll margins and origin mode
1023 /// are reset, the screen is cleared, ratatui's buffer is emptied, and then
1024 /// the full UI is drawn — all within a single DEC 2026 synchronized-update
1025 /// batch so GPU-accelerated terminals (Ghostty, VS Code, Kitty) render one
1026 /// complete frame instead of a blank intermediate frame followed by the UI.
1027 ///
1028 /// When `full_repaint` is false, only the diff from the previous draw is
1029 /// written (normal incremental update path).
1030 pub(crate) fn draw_app_frame_inner(
1031 terminal: &mut AppTerminal,
1032 app: &mut App,
1033 config: &Config,
1034 full_repaint: bool,
1035 ) -> Result<()> {
1036 terminal.backend_mut().set_palette_mode(app.ui_theme.mode);
1037 terminal.backend_mut().set_theme(app.theme_id, app.ui_theme);
1038 // DEC 2026 wrapping is on by default but can be turned off for
1039 // terminals that mishandle it (Ptyxis 50.x + VTE 0.84.x flashes the
1040 // whole viewport on every wrapped frame instead of deferring as the
1041 // standard requires). Settings::synchronized_output_enabled resolves
1042 // the user's setting against the Ptyxis env auto-detect.
1043 let wrap_in_sync_update = app.synchronized_output_enabled;
1044 if wrap_in_sync_update {
1045 let _ = terminal.backend_mut().write_all(BEGIN_SYNC_UPDATE);
1046 }
1047
1048 // Run fallible draw operations in a closure so END_SYNC_UPDATE is
1049 // always sent even if an intermediate step fails. Without this, a
1050 // failing `?` would return early and leave the terminal stuck in
1051 // synchronized-update mode (screen frozen).
1052 let result = (|| -> Result<()> {
1053 if full_repaint {
1054 terminal.backend_mut().write_all(TERMINAL_ORIGIN_RESET)?;
1055 terminal.clear()?;
1056 }
1057 terminal.draw(|f| render(f, app, config))?;
1058 Ok(())
1059 })();
1060
1061 // Always end the synchronized update, regardless of success or failure.
1062 if wrap_in_sync_update {
1063 let _ = terminal.backend_mut().write_all(END_SYNC_UPDATE);
1064 }
1065 let _ = terminal.backend_mut().flush();
1066 result
1067 }
1068
1069 /// Count how many `HistoryCell::User` entries currently live in the
1070 /// transcript. Used by the backtrack state machine to decide whether
1071 /// there's anything to rewind to. Walks `app.history` directly so it
1072 /// stays accurate even mid-stream (the streaming Assistant cell never
1073 /// counts as a user turn).
1074 pub(crate) fn count_user_history_cells(app: &App) -> usize {
1075 app.history
1076 .iter()
1077 .filter(|cell| matches!(cell, HistoryCell::User { .. }))
1078 .count()
1079 }
1080
1081 /// Find the absolute index of the Nth-from-tail `HistoryCell::User` in
1082 /// `app.history`. `depth` of 0 selects the most recent user cell.
1083 /// Returns `None` if `depth` is out of range.
1084 pub(crate) fn find_user_cell_index_from_tail(app: &App, depth: usize) -> Option<usize> {
1085 let mut count = 0usize;
1086 for (idx, cell) in app.history.iter().enumerate().rev() {
1087 if matches!(cell, HistoryCell::User { .. }) {
1088 if count == depth {
1089 return Some(idx);
1090 }
1091 count += 1;
1092 }
1093 }
1094 None
1095 }
1096
1097 /// Truncate `text` to at most `max_chars` characters, cutting at the last
1098 /// natural phrase boundary (`.`, `,`, `:`, `;`, `—`, `-`, or whitespace)
1099 /// so words are never split. Appends `…` only when text was actually cut.
1100 pub(crate) fn short_title_truncate(text: &str, max_chars: usize) -> String {
1101 if text.chars().count() <= max_chars {
1102 return text.to_string();
1103 }
1104 // Look for a natural boundary within the allowed range.
1105 let candidate: String = text.chars().take(max_chars).collect();
1106 let boundary = candidate
1107 .rfind(['.', ',', ':', ';', '—', '-'])
1108 .or_else(|| candidate.rfind(' '))
1109 .unwrap_or(max_chars.min(candidate.len()).saturating_sub(1));
1110 let cut: String = text.chars().take(boundary.max(1)).collect();
1111 format!("{cut}…")
1112 }
1113
1114 pub(crate) fn compact_user_context_display(content: &str) -> String {
1115 content
1116 .split("\n\n---\n\nLocal context from @mentions:")
1117 .next()
1118 .unwrap_or(content)
1119 .to_string()
1120 }
1121
1122 #[cfg(test)]
1123 pub(crate) fn transcript_scroll_percent(top: usize, visible: usize, total: usize) -> Option<u16> {
1124 if total <= visible {
1125 return None;
1126 }
1127
1128 let max_top = total.saturating_sub(visible);
1129 if max_top == 0 {
1130 return None;
1131 }
1132
1133 let clamped_top = top.min(max_top);
1134 let percent = ((clamped_top as f64 / max_top as f64) * 100.0).round() as u16;
1135 Some(percent.min(100))
1136 }
1137
1138 pub(crate) fn estimated_context_tokens(app: &App) -> Option<i64> {
1139 let message_count = app.api_messages.len();
1140 let mut cache = app.context_token_cache.borrow_mut();
1141 if cache.message_tokens.len() > message_count {
1142 cache.message_tokens.truncate(message_count);
1143 }
1144 while cache.message_tokens.len() < message_count {
1145 let index = cache.message_tokens.len();
1146 cache
1147 .message_tokens
1148 .push(estimate_tokens(&app.api_messages[index..=index]));
1149 }
1150 // The final assistant/tool message may grow while streaming. Recompute
1151 // only that tail entry; historical messages remain O(1) on steady frames.
1152 if message_count > 0 {
1153 let last = message_count - 1;
1154 cache.message_tokens[last] = estimate_tokens(&app.api_messages[last..=last]);
1155 }
1156 let message_tokens = cache
1157 .message_tokens
1158 .iter()
1159 .copied()
1160 .sum::<usize>()
1161 .saturating_mul(3)
1162 .div_ceil(2);
1163 let system_tokens =
1164 estimate_input_tokens_conservative(&[], app.system_prompt.as_ref()).saturating_sub(48);
1165 let estimated = message_tokens
1166 .saturating_add(system_tokens)
1167 .saturating_add(message_count.saturating_mul(12))
1168 .saturating_add(48);
1169 i64::try_from(estimated).ok()
1170 }
1171
1172 pub(crate) fn context_usage_snapshot(app: &App) -> Option<(i64, u32, f64)> {
1173 let max = crate::route_budget::route_context_window_tokens(
1174 app.api_provider,
1175 app.effective_model_for_budget(),
1176 app.active_route_limits,
1177 );
1178 context_usage_snapshot_for_window(app, max)
1179 }
1180
1181 pub(crate) fn context_usage_snapshot_for_window(app: &App, max: u32) -> Option<(i64, u32, f64)> {
1182 let max_i64 = i64::from(max);
1183 let reported = app
1184 .session
1185 .last_prompt_tokens
1186 .map(i64::from)
1187 .map(|tokens| tokens.max(0));
1188 let estimated = estimated_context_tokens(app).map(|tokens| tokens.max(0));
1189
1190 // Always prefer the estimated current-context size (computed from
1191 // `app.api_messages`) when we have it. Reported `last_prompt_tokens`
1192 // comes from `Event::TurnComplete.usage`, which the engine builds with
1193 // `turn.add_usage` — that SUMS input_tokens across every round in the
1194 // turn, so a multi-round tool-call turn reports a value much larger
1195 // than the actual context window state, then the next single-round
1196 // turn drops back to a single round's input_tokens. User-visible %
1197 // was bouncing 31% → 9% (#115) because of this. The estimate is
1198 // monotonic wrt conversation growth, which is what a "context filling
1199 // up" indicator should show. We still consult `reported` only as a
1200 // fallback when no estimate is available (e.g., immediately after a
1201 // session restore before the api_messages are populated).
1202 let used = match (estimated, reported) {
1203 (Some(estimated), _) => estimated.min(max_i64),
1204 (None, Some(reported)) => reported.min(max_i64),
1205 (None, None) => return None,
1206 };
1207
1208 let max_f64 = f64::from(max);
1209 let used_f64 = used as f64;
1210 let percent = ((used_f64 / max_f64) * 100.0).clamp(0.0, 100.0);
1211 Some((used, max, percent))
1212 }
1213
1214 /// True while a `workflow` tool is executing in the foreground (active cell)
1215 /// or still shown as running in history. Used to keep per-subagent completion
1216 /// notifications quiet during a workflow run under `final-only`.
1217 pub(crate) fn workflow_tool_is_running(app: &App) -> bool {
1218 fn is_running_workflow(cell: &HistoryCell) -> bool {
1219 matches!(
1220 cell,
1221 HistoryCell::Tool(ToolCell::Generic(tool))
1222 if tool.name == "workflow" && tool.status == ToolStatus::Running
1223 )
1224 }
1225 app.history.iter().any(is_running_workflow)
1226 || app
1227 .active_cell
1228 .as_ref()
1229 .is_some_and(|active| active.entries().iter().any(is_running_workflow))
1230 }
1231
1231 lines RUST