返回 CodeWhale
overlays.rs
根目录 / crates / tui / src / tui / ui / overlays.rs
1 //! Opening, closing, and refreshing the overlay surfaces (pagers, inspectors,
2 //! backtrack, hotbar) layered over the transcript.
3 //!
4 //! Moved verbatim out of `ui.rs`.
5
6 use super::*;
7
8 pub(crate) fn open_setup_checkpoint_if_due(
9 app: &mut App,
10 config: &Config,
11 skip_onboarding: bool,
12 ) -> bool {
13 if skip_onboarding {
14 if crate::tui::setup::should_open_update_checkpoint(app, config)
15 && let Err(err) = crate::tui::setup::defer_update_checkpoint_for_app(app, config)
16 {
17 tracing::warn!(
18 target: "tui::setup",
19 "failed to record deferred setup checkpoint: {err}"
20 );
21 }
22 return false;
23 }
24 if app.onboarding != crate::tui::app::OnboardingState::None
25 || app.view_stack.top_kind() == Some(ModalKind::SetupWizard)
26 || !crate::tui::setup::should_open_update_checkpoint(app, config)
27 {
28 return false;
29 }
30
31 // A fresh wizard invalidates any in-flight model draft from a prior one.
32 let _ = app.next_draft_gen();
33 app.view_stack
34 .push(crate::tui::setup::SetupWizardView::new_checkpoint_for_app(
35 app, config,
36 ));
37 true
38 }
39
40 /// Ctrl+O — the one gesture that selects "explore offline".
41 ///
42 /// A plain letter cannot be used: the provider picker key-entry stage is a
43 /// text field and would swallow it into the draft secret.
44 pub(crate) fn is_explore_offline_shortcut(key: &KeyEvent) -> bool {
45 matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O'))
46 && key.modifiers.contains(KeyModifiers::CONTROL)
47 }
48
49 /// Open the one canonical theme surface (`/theme`) as an ordinary modal.
50 ///
51 /// Onboarding's ready screen exposes this as the optional "customize later"
52 /// secondary action: onboarding is finished first, so the picker is a normal
53 /// modal over the live product. It reuses the same `ThemePickerView`, so the
54 /// preview is live and transactional (Enter persists, Escape reverts) and
55 /// there is no second theme registry.
56 pub(crate) fn open_theme_picker(app: &mut App) {
57 if app.view_stack.top_kind() == Some(ModalKind::ThemePicker) {
58 return;
59 }
60 let original = app.theme_id.name().to_string();
61 app.view_stack
62 .push_boxed(crate::tui::theme_picker::ThemePickerView::boxed(
63 original,
64 app.ui_locale,
65 app.background_color_override,
66 ));
67 app.needs_redraw = true;
68 }
69
70 /// Toggle the one canonical keyboard-oriented Help index.
71 ///
72 /// Launch, onboarding, and the live shell all route here so opening Help from
73 /// a startup row cannot drift into a second catalog or ordering policy.
74 pub(crate) fn toggle_help_view(app: &mut App) {
75 if app.view_stack.top_kind() == Some(ModalKind::Help) {
76 app.view_stack.pop();
77 } else {
78 let help = HelpView::new_for_app(app, true).with_groups_expanded(app.help_expand_groups);
79 app.view_stack.push(help);
80 }
81 app.needs_redraw = true;
82 }
83
84 /// After a shared view closes over the launch screen, bring the launch card
85 /// back: Esc out of the resume picker or the changelog pager returns to the
86 /// card rather than stranding the user on an empty stage. A view that began
87 /// a session (`launch.visible == false`) or a draft in the composer leaves
88 /// the dissolved card alone.
89 pub(crate) fn restore_launch_card_after_view_close(app: &mut App) {
90 if app.launch.visible
91 && app.view_stack.is_empty()
92 && app.launch.dissolve_started_ms.is_some()
93 && app.input.is_empty()
94 {
95 app.launch.restore_card();
96 app.needs_redraw = true;
97 }
98 }
99
100 /// Choose which durable-task summaries should appear in the Work
101 /// sidebar's Tasks panel.
102 ///
103 /// Tasks stamped with the current session owner stay visible on that session's
104 /// live surface. Tasks owned by a different session stay in explicit history
105 /// (`/tasks`) instead of appearing as live workspace work. Legacy unowned
106 /// records fall back to the v0.9.1 timestamp gate: active tasks remain visible,
107 /// while terminal receipts must have both creation and completion times inside
108 /// this TUI session. Durable tasks are stored per user rather than per TUI
109 /// process, and startup recovery can stamp an old running record with a fresh
110 /// `ended_at`. Treating that as a current receipt makes a new same-workspace
111 /// instance look failed (#4416).
112 ///
113 /// A terminal task missing `ended_at` is treated as not current and
114 /// dropped: durable tasks always stamp `ended_at` when they reach a
115 /// terminal state, so absence of it indicates a record from a much
116 /// older schema and isn't worth surfacing.
117 pub(crate) fn select_work_sidebar_tasks(
118 tasks: Vec<TaskSummary>,
119 session_started_at: chrono::DateTime<chrono::Utc>,
120 current_session_id: Option<&str>,
121 ) -> Vec<TaskSummary> {
122 tasks
123 .into_iter()
124 .filter(|task| {
125 let owner_matches_current = current_session_id
126 .zip(task.owner_session_id.as_deref())
127 .is_some_and(|(current, owner)| current == owner);
128 let owned_by_other_session = current_session_id.is_some()
129 && task
130 .owner_session_id
131 .as_deref()
132 .is_some_and(|owner| Some(owner) != current_session_id);
133 if owned_by_other_session {
134 return false;
135 }
136 match task.status {
137 TaskStatus::Queued | TaskStatus::Running => {
138 owner_matches_current || task.owner_session_id.is_none()
139 }
140 TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Canceled => {
141 // A terminal task missing `ended_at` predates the schema
142 // that always stamps it; never surface it as a live
143 // receipt, even when it names this session as owner.
144 if task.ended_at.is_none() {
145 return false;
146 }
147 owner_matches_current
148 || (task.owner_session_id.is_none()
149 && task.created_at >= session_started_at
150 && task
151 .ended_at
152 .is_some_and(|ended_at| ended_at >= session_started_at))
153 }
154 }
155 })
156 .collect()
157 }
158
159 pub(crate) fn toggle_settings_view(app: &mut App) {
160 if app.view_stack.contains_kind(ModalKind::Config) {
161 app.view_stack.pop_through_kind(ModalKind::Config);
162 } else {
163 app.view_stack.push(ConfigView::new_for_app(app));
164 }
165 app.needs_redraw = true;
166 }
167
168 pub(crate) fn clear_work_inspector_after_pager_close(app: &mut App, was_work_inspector: bool) {
169 if was_work_inspector && app.view_stack.top_kind() != Some(ModalKind::Pager) {
170 app.work_surface.opened = None;
171 }
172 }
173
174 pub(crate) fn hotbar_slot_from_key(app: &App, key: &event::KeyEvent) -> Option<u8> {
175 let KeyCode::Char(c) = key.code else {
176 return None;
177 };
178 if !('1'..='8').contains(&c) {
179 return None;
180 }
181 let slot = c.to_digit(10).and_then(|digit| u8::try_from(digit).ok())?;
182
183 if key.modifiers.contains(KeyModifiers::ALT)
184 && !key.modifiers.contains(KeyModifiers::CONTROL)
185 && !key.modifiers.contains(KeyModifiers::SUPER)
186 {
187 if app.onboarding != OnboardingState::None
188 || !app.view_stack.is_empty()
189 || app.is_history_search_active()
190 || !visible_slash_menu_entries(app, SLASH_MENU_LIMIT).is_empty()
191 {
192 return None;
193 }
194
195 return Some(slot);
196 }
197
198 None
199 }
200
201 pub(crate) async fn cycle_permission_posture(
202 app: &mut App,
203 config: &mut Config,
204 engine_handle: &EngineHandle,
205 ) {
206 let control = config.approval_policy_control(
207 app.config_path.as_deref(),
208 app.config_profile.as_deref(),
209 &app.workspace,
210 );
211 let changed = if control == crate::config::ApprovalPolicyControl::RootConfig {
212 app.cycle_root_approval_posture()
213 } else {
214 app.cycle_approval_posture()
215 };
216 if changed {
217 if control == crate::config::ApprovalPolicyControl::RootConfig {
218 config.approval_policy = None;
219 }
220 sync_mode_update(app, engine_handle).await;
221 refresh_config_view_if_open(app, "permission_posture");
222 }
223 }
224
225 /// Open the one canonical provider setup surface for onboarding. Fresh
226 /// onboarding opens on the full provider catalog, hosted providers included
227 /// (#5563), with `L` as the explicit opt-in local-only view. Missing-key
228 /// recovery instead focuses the already-configured route so an exact Kimi
229 /// Code K3 configuration can expose its plan route before a secret is entered.
230 /// Either way the picker opens on the navigable list (#4763): onboarding never
231 /// drops a user straight into a key/OAuth prompt for a route they were not
232 /// shown.
233 pub(crate) async fn open_onboarding_provider_picker(
234 app: &mut App,
235 config: &Config,
236 engine_handle: &EngineHandle,
237 recover_configured_route: bool,
238 ) {
239 if app.onboarding != OnboardingState::Provider
240 || app.view_stack.top_kind() == Some(ModalKind::ProviderPicker)
241 {
242 return;
243 }
244 let runtime_status = query_provider_runtime_status(engine_handle).await;
245 app.view_stack.push(
246 crate::tui::provider_picker::ProviderPickerView::new_for_onboarding(
247 app.api_provider,
248 recover_configured_route.then(|| app.onboarding_provider.as_str().into()),
249 config,
250 runtime_status,
251 )
252 .with_locale(app.ui_locale)
253 .with_provider_health(&app.provider_health),
254 );
255 app.needs_redraw = true;
256 }
257
258 /// Open the existing provider picker from the post-onboarding launch screen.
259 ///
260 /// The launch row contributes only a `ProviderSetupIntent`; provider catalog,
261 /// health, selection memory, and apply semantics remain owned by
262 /// `ProviderPickerView` and the normal provider event handlers.
263 pub(crate) async fn open_launch_provider_picker(
264 app: &mut App,
265 config: &Config,
266 engine_handle: &EngineHandle,
267 ) {
268 if app.view_stack.top_kind() == Some(ModalKind::ProviderPicker) {
269 return;
270 }
271 let runtime_status = query_provider_runtime_status(engine_handle).await;
272 app.view_stack.push(
273 crate::tui::provider_picker::ProviderPickerView::new_with_runtime_status_and_memory(
274 app.api_provider,
275 config,
276 runtime_status,
277 app.provider_picker_memory.as_ref(),
278 )
279 .with_locale(app.ui_locale)
280 .with_provider_health(&app.provider_health),
281 );
282 app.needs_redraw = true;
283 }
284
285 /// Open the existing provider/route surface from any shell entry point.
286 ///
287 /// The chrome and `/provider` command both delegate here, so they expose the
288 /// same picker without duplicating catalog or runtime-readiness facts. A
289 /// picker preview remains non-authoritative until its normal apply handler
290 /// commits a route.
291 pub(crate) async fn open_provider_picker(
292 app: &mut App,
293 config: &Config,
294 engine_handle: &EngineHandle,
295 ) {
296 if app.onboarding == OnboardingState::Provider {
297 let recover_configured_route = app.onboarding_recovers_configured_route();
298 open_onboarding_provider_picker(app, config, engine_handle, recover_configured_route).await;
299 } else {
300 open_launch_provider_picker(app, config, engine_handle).await;
301 }
302 }
303
304 pub(crate) fn open_text_pager(app: &mut App, title: String, content: String) {
305 let width = app
306 .viewport
307 .last_transcript_area
308 .map(|area| area.width)
309 .unwrap_or(80);
310 app.view_stack.push(PagerView::from_text(
311 title,
312 &content,
313 width.saturating_sub(2),
314 ));
315 }
316
317 pub(crate) fn open_context_inspector(app: &mut App) {
318 app.view_stack.push(ContextInspectorView::new(app));
319 }
320
321 pub(crate) fn open_external_url(url: &str) -> Result<()> {
322 crate::utils::open_url(url)
323 }
324
325 /// Pull the latest snapshot of cells / revisions / render options into the
326 /// live transcript overlay sitting on top of the view stack. No-op if the
327 /// top view isn't a `LiveTranscriptOverlay`.
328 pub(crate) fn refresh_live_transcript_overlay(app: &mut App) {
329 // Pop+push lets us hold &mut to the overlay while also borrowing `app`
330 // mutably for the snapshot — direct re-borrow through `view_stack`
331 // would otherwise alias `app`.
332 let Some(mut overlay) = app.view_stack.pop() else {
333 return;
334 };
335 if let Some(typed) = overlay.as_any_mut().downcast_mut::<LiveTranscriptOverlay>() {
336 typed.refresh_from_app(app);
337 }
338 app.view_stack.push_boxed(overlay);
339 }
340
341 pub(crate) fn refresh_context_inspector_overlay(app: &mut App) {
342 let Some(mut overlay) = app.view_stack.pop() else {
343 return;
344 };
345 if let Some(typed) = overlay.as_any_mut().downcast_mut::<ContextInspectorView>() {
346 typed.refresh_from_app(app);
347 }
348 app.view_stack.push_boxed(overlay);
349 }
350
351 /// Open the live transcript overlay in backtrack-preview mode (#133).
352 /// The overlay starts highlighting the most recent user message
353 /// (`selected_idx = 0`) and routes Left/Right/Enter/Esc through
354 /// `ViewEvent::Backtrack*` so the main key dispatcher can advance the
355 /// `BacktrackState` and apply the rewind on confirm.
356 pub(crate) fn open_backtrack_overlay(app: &mut App) {
357 let mut overlay = LiveTranscriptOverlay::new();
358 overlay.refresh_from_app(app);
359 overlay.set_backtrack_preview(0);
360 app.view_stack.push(overlay);
361 app.status_message =
362 Some("Backtrack: \u{2190}/\u{2192} step Enter rewind Esc cancel".to_string());
363 app.needs_redraw = true;
364 }
365
366 /// Open a fresh live transcript overlay in sticky-tail mode.
367 pub(crate) fn open_live_transcript_overlay(app: &mut App) {
368 if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
369 return;
370 }
371 let mut overlay = LiveTranscriptOverlay::new();
372 overlay.refresh_from_app(app);
373 app.view_stack.push(overlay);
374 app.status_message = Some("Live transcript: tailing (Esc to close)".to_string());
375 app.needs_redraw = true;
376 }
377
378 /// Toggle the live transcript overlay on `Ctrl+Shift+T`. Closes the overlay if it's
379 /// already on top; otherwise uses the same open path as `/transcript`.
380 pub(crate) fn toggle_live_transcript_overlay(app: &mut App) {
381 if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
382 app.view_stack.pop();
383 app.needs_redraw = true;
384 return;
385 }
386 open_live_transcript_overlay(app);
387 }
388
389 /// Open the `/model` picker pre-filtered to `provider` (#3083). The model
390 /// picker's search already scopes rows by provider display name, so we reuse
391 /// the standard "open model picker" path and seed its query by replaying the
392 /// provider's display name as character input through the public view-stack
393 /// key path — no model-picker internals are touched.
394 pub(crate) fn open_model_picker_for_provider(
395 app: &mut App,
396 config: &Config,
397 identity: &crate::config::ProviderIdentity,
398 ) {
399 if let Err(reason) = config.verify_provider_identity(identity) {
400 app.push_status_toast(reason, StatusToastLevel::Error, Some(8_000));
401 return;
402 }
403 if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
404 app.view_stack
405 .push(crate::tui::model_picker::ModelPickerView::new(app, config));
406 }
407 for ch in identity.key.as_str().chars() {
408 // Char input updates the query and never emits a ViewEvent, so the
409 // returned (empty) event list is safe to drop.
410 let _ = app.view_stack.handle_key(crossterm::event::KeyEvent::new(
411 KeyCode::Char(ch),
412 KeyModifiers::NONE,
413 ));
414 }
415 app.needs_redraw = true;
416 }
417
418 /// Hide the Hotbar: persist `hotbar = []` (the canonical "disabled" state) and
419 /// clear the live in-memory slots so the panel disappears immediately. The
420 /// explicit empty array — not a missing key — is what disables defaults, so we
421 /// store `Some(vec![])` rather than `None`.
422 pub(crate) fn disable_hotbar(app: &mut App, config: &mut Config) {
423 match crate::config_persistence::persist_hotbar_bindings(app.config_path.as_deref(), &[]) {
424 Ok(path) => {
425 config.hotbar = Some(Vec::new());
426 app.status_message = Some(format!(
427 "Hotbar hidden (hotbar = [] in {}). Bring it back with `/hotbar on`.",
428 path.display()
429 ));
430 }
431 Err(err) => {
432 app.status_message = Some(format!("Failed to hide Hotbar: {err}"));
433 app.add_message(HistoryCell::System {
434 content: format!("Failed to hide Hotbar: {err}"),
435 });
436 }
437 }
438 app.needs_redraw = true;
439 }
440
441 pub(crate) fn refresh_config_view_if_open(app: &mut App, focus_key: &str) {
442 refresh_config_view_after_commit(app, focus_key, false);
443 }
444
445 /// Rebuild an open settings view from disk truth after a commit. When the host
446 /// rejected the value, the editor reopens with what the user typed.
447 pub(crate) fn refresh_config_view_after_commit(app: &mut App, focus_key: &str, rejected: bool) {
448 if app.view_stack.top_kind() != Some(ModalKind::Config) {
449 return;
450 }
451 let Some(mut boxed) = app.view_stack.pop() else {
452 return;
453 };
454 let rebuilt = match boxed.as_any_mut().downcast_ref::<ConfigView>() {
455 Some(previous) => {
456 let mut view = ConfigView::rebuild_preserving(app, previous, focus_key);
457 if rejected {
458 view.restore_rejected_commit(previous);
459 }
460 view
461 }
462 // Not a `ConfigView`: rebuild from scratch rather than restoring an
463 // unknown modal, matching how the stack got here.
464 None => {
465 let mut fresh = ConfigView::new_for_app(app);
466 fresh.focus_key(focus_key);
467 fresh
468 }
469 };
470 app.view_stack.push(rebuilt);
471 }
472
473 pub(crate) fn refresh_skills_manager_if_open(
474 app: &mut App,
475 status: Option<String>,
476 focus: Option<&crate::skills::audit::AuditedSkillId>,
477 ) {
478 if app.view_stack.top_kind() != Some(ModalKind::SkillsManager) {
479 return;
480 }
481 let Some(mut boxed) = app.view_stack.pop() else {
482 return;
483 };
484 let rebuilt = if let Some(prev) = boxed
485 .as_any_mut()
486 .downcast_mut::<crate::tui::views::skills_manager::SkillsManagerView>(
487 ) {
488 crate::tui::views::skills_manager::SkillsManagerView::rebuild_preserving(
489 app, prev, status, focus,
490 )
491 } else {
492 crate::tui::views::skills_manager::SkillsManagerView::new(app)
493 };
494 app.view_stack.push(rebuilt);
495 }
496
497 #[allow(clippy::too_many_arguments)]
498 pub(crate) fn push_approval_request_view(
499 app: &mut App,
500 id: &str,
501 tool_name: &str,
502 description: &str,
503 tool_input: &serde_json::Value,
504 approval_key: &str,
505 approval_grouping_key: &str,
506 intent_summary: Option<&str>,
507 default_selection: crate::config::ApprovalDefaultSelection,
508 timeout: Option<std::time::Duration>,
509 ) {
510 let mut request = ApprovalRequest::new_with_intent(
511 id,
512 tool_name,
513 description,
514 tool_input,
515 approval_key,
516 intent_summary,
517 &app.workspace,
518 );
519 // The engine owns the grant scope: a child's grouping key is prefixed
520 // with its agent so "allow for this conversation" never covers the
521 // parent or a sibling (C3). Keep the card's own key only when the
522 // event carries none.
523 if !approval_grouping_key.is_empty() {
524 request.approval_grouping_key = approval_grouping_key.to_string();
525 }
526 // A child's gate never consults saved repo allow rules, so a child card
527 // must not offer "Always allow in this repo" — it would do nothing. The
528 // card names its agent (approvals C1).
529 if crate::tools::subagent::SubAgentManager::is_child_approval_id(id) {
530 request.persistent_allow_rules.clear();
531 if let Some(agent_id) = crate::tui::pending_requests::child_agent_id(id) {
532 request.owner = Some(crate::tui::pending_requests::owner_for(app, agent_id));
533 }
534 }
535 app.view_stack.push(
536 ApprovalView::new_with_default_selection(request, app.ui_locale, default_selection)
537 .with_timeout(timeout),
538 );
539 }
540
541 /// Push the new `selected_idx` into the live transcript overlay so the
542 /// highlight follows the user's Left/Right input. No-op if the overlay is
543 /// no longer on top (e.g. it was closed underneath us).
544 pub(crate) fn update_backtrack_overlay_selection(app: &mut App, selected_idx: usize) {
545 if app.view_stack.top_kind() != Some(ModalKind::LiveTranscript) {
546 return;
547 }
548 let Some(mut overlay) = app.view_stack.pop() else {
549 return;
550 };
551 if let Some(typed) = overlay.as_any_mut().downcast_mut::<LiveTranscriptOverlay>() {
552 typed.set_backtrack_preview(selected_idx);
553 }
554 app.view_stack.push_boxed(overlay);
555 app.needs_redraw = true;
556 }
557
558 /// Apply the user's backtrack selection: trim `app.history` and
559 /// `app.api_messages` so everything from the chosen user message onward
560 /// is dropped, populate the composer with the dropped user text, close
561 /// the overlay, and surface a status hint. The cycle counter is bumped
562 /// so any persistent indices clear; the engine's in-flight context is
563 /// re-synced via `Op::SyncSession` so the next turn starts fresh.
564 /// Index in `api_messages` to truncate to for a backtrack of `depth` visible
565 /// user prompts from the tail. Counts only messages that yield a
566 /// `HistoryCell::User` (a real prompt), NOT tool-result messages which are
567 /// also stored with `role == "user"`. Returns `None` if fewer than `depth`
568 /// user prompts exist.
569 pub(crate) fn backtrack_api_cut_index(api_messages: &[Message], depth: usize) -> Option<usize> {
570 let mut user_seen = 0usize;
571 for (idx, msg) in api_messages.iter().enumerate().rev() {
572 let yields_user = history_cells_from_message(msg)
573 .iter()
574 .any(|cell| matches!(cell, HistoryCell::User { .. }));
575 if yields_user {
576 if user_seen == depth {
577 return Some(idx);
578 }
579 user_seen += 1;
580 }
581 }
582 None
583 }
584
585 pub(crate) fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool {
586 let line_meta = app.viewport.transcript_cache.line_meta();
587 if line_meta.is_empty() {
588 return false;
589 }
590
591 let top = app
592 .viewport
593 .last_transcript_top
594 .min(line_meta.len().saturating_sub(1));
595 let current_cell = line_meta
596 .get(top)
597 .and_then(crate::tui::scrolling::TranscriptLineMeta::cell_line)
598 .map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index));
599
600 let mut scan_indices = Vec::new();
601 match direction {
602 SearchDirection::Forward => {
603 scan_indices.extend((top.saturating_add(1))..line_meta.len());
604 }
605 SearchDirection::Backward => {
606 scan_indices.extend((0..top).rev());
607 }
608 }
609
610 for idx in scan_indices {
611 let Some((cell_index, _)) = line_meta[idx].cell_line() else {
612 continue;
613 };
614 let cell_index = app.original_cell_index_for_rendered(cell_index);
615 if current_cell.is_some_and(|current| current == cell_index) {
616 continue;
617 }
618 if !matches!(app.history.get(cell_index), Some(HistoryCell::Tool(_))) {
619 continue;
620 }
621 if let Some(anchor) = TranscriptScroll::anchor_for(line_meta, idx) {
622 app.viewport.transcript_scroll = anchor;
623 app.viewport.pending_scroll_delta = 0;
624 app.needs_redraw = true;
625 return true;
626 }
627 }
628
629 false
630 }
631
632 pub(crate) fn open_pager_for_selection(app: &mut App) -> bool {
633 let Some(text) = selection_to_text(app) else {
634 return false;
635 };
636 let width = app
637 .viewport
638 .last_transcript_area
639 .map(|area| area.width)
640 .unwrap_or(80);
641 let pager = PagerView::from_text("Selection", &text, width.saturating_sub(2));
642 app.view_stack.push(pager);
643 true
644 }
645
646 pub(crate) fn open_pager_for_last_message(app: &mut App) -> bool {
647 let Some(cell) = app.history.last() else {
648 return false;
649 };
650 let width = app
651 .viewport
652 .last_transcript_area
653 .map(|area| area.width)
654 .unwrap_or(80);
655 let text = history_cell_to_text(cell, width);
656 let mut pager = PagerView::from_text("Message", &text, width.saturating_sub(2));
657 // When the last message is a completed assistant answer, expose the
658 // clean answer-only payload via `a` (copy answer) — the rendered body
659 // that `c`/`y` copies still carries the glyph/label line.
660 if let Some(answer) = completed_assistant_answer_text(cell, width) {
661 pager = pager.with_copy_answer(answer);
662 }
663 app.view_stack.push(pager);
664 true
665 }
666
667 /// Compatibility wrapper for tests that exercise Ctrl+O on a thinking cell.
668 /// The user-facing Ctrl+O surface is now the turn-scoped Reasoning Detail
669 /// pager (#v092-reasoning-fix).
670 #[cfg(test)]
671 pub(crate) fn open_thinking_pager(app: &mut App) -> bool {
672 open_reasoning_detail_pager(app)
673 }
674
674 lines RUST