返回 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_for_app(app, config));
35 true
36 }
37
38 /// Ctrl+O — the one gesture that selects "explore offline".
39 ///
40 /// A plain letter cannot be used: the provider picker key-entry stage is a
41 /// text field and would swallow it into the draft secret.
42 pub(crate) fn is_explore_offline_shortcut(key: &KeyEvent) -> bool {
43 matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O'))
44 && key.modifiers.contains(KeyModifiers::CONTROL)
45 }
46
47 /// Open the one canonical theme surface for the appearance step (#3937).
48 ///
49 /// This is the same `ThemePickerView` `/theme` uses, so onboarding inherits its
50 /// transactional contract wholesale: navigating previews live through a
51 /// non-persisting `ConfigUpdated`, Enter persists, and Escape reverts to the
52 /// theme captured here. There is no second theme list and no second registry.
53 pub(crate) fn open_onboarding_theme_picker(app: &mut App) {
54 if app.onboarding != OnboardingState::Appearance
55 || app.view_stack.top_kind() == Some(ModalKind::ThemePicker)
56 {
57 return;
58 }
59 let original = app.theme_id.name().to_string();
60 app.view_stack.push_boxed(
61 crate::tui::theme_picker::ThemePickerView::boxed_with_treatment(
62 original,
63 app.ocean_treatment,
64 app.ui_locale,
65 app.background_color_override,
66 ),
67 );
68 app.needs_redraw = true;
69 }
70
71 /// Choose which durable-task summaries should appear in the Work
72 /// sidebar's Tasks panel.
73 ///
74 /// Tasks stamped with the current session owner stay visible on that session's
75 /// live surface. Tasks owned by a different session stay in explicit history
76 /// (`/tasks`) instead of appearing as live workspace work. Legacy unowned
77 /// records fall back to the v0.9.1 timestamp gate: active tasks remain visible,
78 /// while terminal receipts must have both creation and completion times inside
79 /// this TUI session. Durable tasks are stored per user rather than per TUI
80 /// process, and startup recovery can stamp an old running record with a fresh
81 /// `ended_at`. Treating that as a current receipt makes a new same-workspace
82 /// instance look failed (#4416).
83 ///
84 /// A terminal task missing `ended_at` is treated as not current and
85 /// dropped: durable tasks always stamp `ended_at` when they reach a
86 /// terminal state, so absence of it indicates a record from a much
87 /// older schema and isn't worth surfacing.
88 pub(crate) fn select_work_sidebar_tasks(
89 tasks: Vec<TaskSummary>,
90 session_started_at: chrono::DateTime<chrono::Utc>,
91 current_session_id: Option<&str>,
92 ) -> Vec<TaskSummary> {
93 tasks
94 .into_iter()
95 .filter(|task| {
96 let owner_matches_current = current_session_id
97 .zip(task.owner_session_id.as_deref())
98 .is_some_and(|(current, owner)| current == owner);
99 let owned_by_other_session = current_session_id.is_some()
100 && task
101 .owner_session_id
102 .as_deref()
103 .is_some_and(|owner| Some(owner) != current_session_id);
104 if owned_by_other_session {
105 return false;
106 }
107 match task.status {
108 TaskStatus::Queued | TaskStatus::Running => {
109 owner_matches_current || task.owner_session_id.is_none()
110 }
111 TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Canceled => {
112 // A terminal task missing `ended_at` predates the schema
113 // that always stamps it; never surface it as a live
114 // receipt, even when it names this session as owner.
115 if task.ended_at.is_none() {
116 return false;
117 }
118 owner_matches_current
119 || (task.owner_session_id.is_none()
120 && task.created_at >= session_started_at
121 && task
122 .ended_at
123 .is_some_and(|ended_at| ended_at >= session_started_at))
124 }
125 }
126 })
127 .collect()
128 }
129
130 pub(crate) fn toggle_settings_view(app: &mut App) {
131 if app.view_stack.contains_kind(ModalKind::Config) {
132 app.view_stack.pop_through_kind(ModalKind::Config);
133 } else {
134 app.view_stack.push(ConfigView::new_for_app(app));
135 }
136 app.needs_redraw = true;
137 }
138
139 pub(crate) fn clear_work_inspector_after_pager_close(app: &mut App, was_work_inspector: bool) {
140 if was_work_inspector && app.view_stack.top_kind() != Some(ModalKind::Pager) {
141 app.work_surface.opened = None;
142 }
143 }
144
145 pub(crate) fn hotbar_slot_from_key(app: &App, key: &event::KeyEvent) -> Option<u8> {
146 let KeyCode::Char(c) = key.code else {
147 return None;
148 };
149 if !('1'..='8').contains(&c) {
150 return None;
151 }
152 let slot = c.to_digit(10).and_then(|digit| u8::try_from(digit).ok())?;
153
154 if key.modifiers.contains(KeyModifiers::ALT)
155 && !key.modifiers.contains(KeyModifiers::CONTROL)
156 && !key.modifiers.contains(KeyModifiers::SUPER)
157 {
158 if app.onboarding != OnboardingState::None
159 || !app.view_stack.is_empty()
160 || app.is_history_search_active()
161 || app.decision_card.is_some()
162 || !visible_slash_menu_entries(app, SLASH_MENU_LIMIT).is_empty()
163 {
164 return None;
165 }
166
167 return Some(slot);
168 }
169
170 None
171 }
172
173 pub(crate) fn decision_card_number_from_key(key: &event::KeyEvent) -> Option<usize> {
174 let KeyCode::Char(c @ '1'..='9') = key.code else {
175 return None;
176 };
177 if !key.modifiers.is_empty() {
178 return None;
179 }
180
181 Some((c as u8 - b'1' + 1) as usize)
182 }
183
184 pub(crate) fn is_permission_cycle_shortcut(key: &KeyEvent) -> bool {
185 let forbidden = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER;
186 if key.modifiers.intersects(forbidden) {
187 return false;
188 }
189 matches!(key.code, KeyCode::BackTab)
190 || (matches!(key.code, KeyCode::Tab) && key.modifiers.contains(KeyModifiers::SHIFT))
191 }
192
193 pub(crate) async fn cycle_permission_posture(
194 app: &mut App,
195 config: &mut Config,
196 engine_handle: &EngineHandle,
197 ) {
198 let control = config.approval_policy_control(
199 app.config_path.as_deref(),
200 app.config_profile.as_deref(),
201 &app.workspace,
202 );
203 let changed = if control == crate::config::ApprovalPolicyControl::RootConfig {
204 app.cycle_root_approval_posture()
205 } else {
206 app.cycle_approval_posture()
207 };
208 if changed {
209 if control == crate::config::ApprovalPolicyControl::RootConfig {
210 config.approval_policy = None;
211 }
212 sync_mode_update(app, engine_handle).await;
213 refresh_config_view_if_open(app, "permission_posture");
214 }
215 }
216
217 /// Open the one canonical provider setup surface for onboarding. Fresh
218 /// onboarding starts at the full catalog; missing-key recovery focuses the
219 /// current route so an exact Kimi Code K3 configuration can expose its plan
220 /// route before a secret is entered. Either way the picker opens on the
221 /// navigable list (#4763): onboarding never drops a user straight into a
222 /// key/OAuth prompt for a route they were not shown.
223 pub(crate) async fn open_onboarding_provider_picker(
224 app: &mut App,
225 config: &Config,
226 engine_handle: &EngineHandle,
227 focus_current_route: bool,
228 ) {
229 if app.onboarding != OnboardingState::Provider
230 || app.view_stack.top_kind() == Some(ModalKind::ProviderPicker)
231 {
232 return;
233 }
234 let runtime_status = query_provider_runtime_status(engine_handle).await;
235 app.view_stack.push(
236 crate::tui::provider_picker::ProviderPickerView::new_for_onboarding(
237 app.api_provider,
238 focus_current_route.then_some(app.onboarding_provider),
239 config,
240 runtime_status,
241 )
242 .with_locale(app.ui_locale)
243 .with_provider_health(&app.provider_health),
244 );
245 app.needs_redraw = true;
246 }
247
248 pub(crate) fn open_text_pager(app: &mut App, title: String, content: String) {
249 let width = app
250 .viewport
251 .last_transcript_area
252 .map(|area| area.width)
253 .unwrap_or(80);
254 app.view_stack.push(PagerView::from_text(
255 title,
256 &content,
257 width.saturating_sub(2),
258 ));
259 }
260
261 pub(crate) fn open_context_inspector(app: &mut App) {
262 app.view_stack.push(ContextInspectorView::new(app));
263 }
264
265 pub(crate) fn open_external_url(url: &str) -> Result<()> {
266 crate::utils::open_url(url)
267 }
268
269 /// Pull the latest snapshot of cells / revisions / render options into the
270 /// live transcript overlay sitting on top of the view stack. No-op if the
271 /// top view isn't a `LiveTranscriptOverlay`.
272 pub(crate) fn refresh_live_transcript_overlay(app: &mut App) {
273 // Pop+push lets us hold &mut to the overlay while also borrowing `app`
274 // mutably for the snapshot — direct re-borrow through `view_stack`
275 // would otherwise alias `app`.
276 let Some(mut overlay) = app.view_stack.pop() else {
277 return;
278 };
279 if let Some(typed) = overlay.as_any_mut().downcast_mut::<LiveTranscriptOverlay>() {
280 typed.refresh_from_app(app);
281 }
282 app.view_stack.push_boxed(overlay);
283 }
284
285 pub(crate) fn refresh_context_inspector_overlay(app: &mut App) {
286 let Some(mut overlay) = app.view_stack.pop() else {
287 return;
288 };
289 if let Some(typed) = overlay.as_any_mut().downcast_mut::<ContextInspectorView>() {
290 typed.refresh_from_app(app);
291 }
292 app.view_stack.push_boxed(overlay);
293 }
294
295 /// Open the live transcript overlay in backtrack-preview mode (#133).
296 /// The overlay starts highlighting the most recent user message
297 /// (`selected_idx = 0`) and routes Left/Right/Enter/Esc through
298 /// `ViewEvent::Backtrack*` so the main key dispatcher can advance the
299 /// `BacktrackState` and apply the rewind on confirm.
300 pub(crate) fn open_backtrack_overlay(app: &mut App) {
301 let mut overlay = LiveTranscriptOverlay::new();
302 overlay.refresh_from_app(app);
303 overlay.set_backtrack_preview(0);
304 app.view_stack.push(overlay);
305 app.status_message =
306 Some("Backtrack: \u{2190}/\u{2192} step Enter rewind Esc cancel".to_string());
307 app.needs_redraw = true;
308 }
309
310 /// Open a fresh live transcript overlay in sticky-tail mode.
311 pub(crate) fn open_live_transcript_overlay(app: &mut App) {
312 if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
313 return;
314 }
315 let mut overlay = LiveTranscriptOverlay::new();
316 overlay.refresh_from_app(app);
317 app.view_stack.push(overlay);
318 app.status_message = Some("Live transcript: tailing (Esc to close)".to_string());
319 app.needs_redraw = true;
320 }
321
322 /// Toggle the live transcript overlay on `Ctrl+Shift+T`. Closes the overlay if it's
323 /// already on top; otherwise uses the same open path as `/transcript`.
324 pub(crate) fn toggle_live_transcript_overlay(app: &mut App) {
325 if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
326 app.view_stack.pop();
327 app.needs_redraw = true;
328 return;
329 }
330 open_live_transcript_overlay(app);
331 }
332
333 /// Open the `/model` picker pre-filtered to `provider` (#3083). The model
334 /// picker's search already scopes rows by provider display name, so we reuse
335 /// the standard "open model picker" path and seed its query by replaying the
336 /// provider's display name as character input through the public view-stack
337 /// key path — no model-picker internals are touched.
338 pub(crate) fn open_model_picker_for_provider(
339 app: &mut App,
340 config: &Config,
341 provider: crate::config::ApiProvider,
342 ) {
343 if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
344 app.view_stack
345 .push(crate::tui::model_picker::ModelPickerView::new(app, config));
346 }
347 for ch in provider.display_name().chars() {
348 // Char input updates the query and never emits a ViewEvent, so the
349 // returned (empty) event list is safe to drop.
350 let _ = app.view_stack.handle_key(crossterm::event::KeyEvent::new(
351 KeyCode::Char(ch),
352 KeyModifiers::NONE,
353 ));
354 }
355 app.needs_redraw = true;
356 }
357
358 /// Hide the Hotbar: persist `hotbar = []` (the canonical "disabled" state) and
359 /// clear the live in-memory slots so the panel disappears immediately. The
360 /// explicit empty array — not a missing key — is what disables defaults, so we
361 /// store `Some(vec![])` rather than `None`.
362 pub(crate) fn disable_hotbar(app: &mut App, config: &mut Config) {
363 match crate::config_persistence::persist_hotbar_bindings(app.config_path.as_deref(), &[]) {
364 Ok(path) => {
365 config.hotbar = Some(Vec::new());
366 app.status_message = Some(format!(
367 "Hotbar hidden (hotbar = [] in {}). Bring it back with `/hotbar on`.",
368 path.display()
369 ));
370 }
371 Err(err) => {
372 app.status_message = Some(format!("Failed to hide Hotbar: {err}"));
373 app.add_message(HistoryCell::System {
374 content: format!("Failed to hide Hotbar: {err}"),
375 });
376 }
377 }
378 app.needs_redraw = true;
379 }
380
381 pub(crate) fn refresh_config_view_if_open(app: &mut App, focus_key: &str) {
382 if app.view_stack.top_kind() == Some(ModalKind::Config) {
383 let filter = app.view_stack.pop().and_then(|mut view| {
384 view.as_any_mut()
385 .downcast_mut::<ConfigView>()
386 .map(|config_view| config_view.filter_query().to_string())
387 });
388 let mut config_view = ConfigView::new_for_app(app);
389 if let Some(filter) = filter {
390 config_view.restore_filter(filter);
391 }
392 config_view.focus_key(focus_key);
393 app.view_stack.push(config_view);
394 }
395 }
396
397 pub(crate) fn refresh_skills_manager_if_open(
398 app: &mut App,
399 status: Option<String>,
400 focus: Option<&crate::skills::audit::AuditedSkillId>,
401 ) {
402 if app.view_stack.top_kind() != Some(ModalKind::SkillsManager) {
403 return;
404 }
405 let Some(mut boxed) = app.view_stack.pop() else {
406 return;
407 };
408 let rebuilt = if let Some(prev) = boxed
409 .as_any_mut()
410 .downcast_mut::<crate::tui::views::skills_manager::SkillsManagerView>(
411 ) {
412 crate::tui::views::skills_manager::SkillsManagerView::rebuild_preserving(
413 app, prev, status, focus,
414 )
415 } else {
416 crate::tui::views::skills_manager::SkillsManagerView::new(app)
417 };
418 app.view_stack.push(rebuilt);
419 }
420
421 pub(crate) fn push_approval_request_view(
422 app: &mut App,
423 id: &str,
424 tool_name: &str,
425 description: &str,
426 tool_input: &serde_json::Value,
427 approval_key: &str,
428 intent_summary: Option<&str>,
429 ) {
430 let request = ApprovalRequest::new_with_intent(
431 id,
432 tool_name,
433 description,
434 tool_input,
435 approval_key,
436 intent_summary,
437 &app.workspace,
438 );
439 app.view_stack
440 .push(ApprovalView::new_for_locale(request, app.ui_locale));
441 }
442
443 /// Push the new `selected_idx` into the live transcript overlay so the
444 /// highlight follows the user's Left/Right input. No-op if the overlay is
445 /// no longer on top (e.g. it was closed underneath us).
446 pub(crate) fn update_backtrack_overlay_selection(app: &mut App, selected_idx: usize) {
447 if app.view_stack.top_kind() != Some(ModalKind::LiveTranscript) {
448 return;
449 }
450 let Some(mut overlay) = app.view_stack.pop() else {
451 return;
452 };
453 if let Some(typed) = overlay.as_any_mut().downcast_mut::<LiveTranscriptOverlay>() {
454 typed.set_backtrack_preview(selected_idx);
455 }
456 app.view_stack.push_boxed(overlay);
457 app.needs_redraw = true;
458 }
459
460 /// Apply the user's backtrack selection: trim `app.history` and
461 /// `app.api_messages` so everything from the chosen user message onward
462 /// is dropped, populate the composer with the dropped user text, close
463 /// the overlay, and surface a status hint. The cycle counter is bumped
464 /// so any persistent indices clear; the engine's in-flight context is
465 /// re-synced via `Op::SyncSession` so the next turn starts fresh.
466 /// Index in `api_messages` to truncate to for a backtrack of `depth` visible
467 /// user prompts from the tail. Counts only messages that yield a
468 /// `HistoryCell::User` (a real prompt), NOT tool-result messages which are
469 /// also stored with `role == "user"`. Returns `None` if fewer than `depth`
470 /// user prompts exist.
471 pub(crate) fn backtrack_api_cut_index(api_messages: &[Message], depth: usize) -> Option<usize> {
472 let mut user_seen = 0usize;
473 for (idx, msg) in api_messages.iter().enumerate().rev() {
474 let yields_user = history_cells_from_message(msg)
475 .iter()
476 .any(|cell| matches!(cell, HistoryCell::User { .. }));
477 if yields_user {
478 if user_seen == depth {
479 return Some(idx);
480 }
481 user_seen += 1;
482 }
483 }
484 None
485 }
486
487 pub(crate) fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool {
488 let line_meta = app.viewport.transcript_cache.line_meta();
489 if line_meta.is_empty() {
490 return false;
491 }
492
493 let top = app
494 .viewport
495 .last_transcript_top
496 .min(line_meta.len().saturating_sub(1));
497 let current_cell = line_meta
498 .get(top)
499 .and_then(crate::tui::scrolling::TranscriptLineMeta::cell_line)
500 .map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index));
501
502 let mut scan_indices = Vec::new();
503 match direction {
504 SearchDirection::Forward => {
505 scan_indices.extend((top.saturating_add(1))..line_meta.len());
506 }
507 SearchDirection::Backward => {
508 scan_indices.extend((0..top).rev());
509 }
510 }
511
512 for idx in scan_indices {
513 let Some((cell_index, _)) = line_meta[idx].cell_line() else {
514 continue;
515 };
516 let cell_index = app.original_cell_index_for_rendered(cell_index);
517 if current_cell.is_some_and(|current| current == cell_index) {
518 continue;
519 }
520 if !matches!(app.history.get(cell_index), Some(HistoryCell::Tool(_))) {
521 continue;
522 }
523 if let Some(anchor) = TranscriptScroll::anchor_for(line_meta, idx) {
524 app.viewport.transcript_scroll = anchor;
525 app.viewport.pending_scroll_delta = 0;
526 app.needs_redraw = true;
527 return true;
528 }
529 }
530
531 false
532 }
533
534 pub(crate) fn open_pager_for_selection(app: &mut App) -> bool {
535 let Some(text) = selection_to_text(app) else {
536 return false;
537 };
538 let width = app
539 .viewport
540 .last_transcript_area
541 .map(|area| area.width)
542 .unwrap_or(80);
543 let pager = PagerView::from_text("Selection", &text, width.saturating_sub(2));
544 app.view_stack.push(pager);
545 true
546 }
547
548 pub(crate) fn open_pager_for_last_message(app: &mut App) -> bool {
549 let Some(cell) = app.history.last() else {
550 return false;
551 };
552 let width = app
553 .viewport
554 .last_transcript_area
555 .map(|area| area.width)
556 .unwrap_or(80);
557 let text = history_cell_to_text(cell, width);
558 let pager = PagerView::from_text("Message", &text, width.saturating_sub(2));
559 app.view_stack.push(pager);
560 true
561 }
562
563 /// Compatibility wrapper for tests that exercise Ctrl+O on a thinking cell.
564 /// The user-facing Ctrl+O surface is now the turn-scoped Reasoning Detail
565 /// pager (#v092-reasoning-fix).
566 #[cfg(test)]
567 pub(crate) fn open_thinking_pager(app: &mut App) -> bool {
568 open_reasoning_detail_pager(app)
569 }
570
570 lines RUST