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