| 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 |