use super::*;
pub(crate) fn open_setup_checkpoint_if_due(
app: &mut App,
config: &Config,
skip_onboarding: bool,
) -> bool {
if skip_onboarding {
if crate::tui::setup::should_open_update_checkpoint(app, config)
&& let Err(err) = crate::tui::setup::defer_update_checkpoint_for_app(app, config)
{
tracing::warn!(
target: "tui::setup",
"failed to record deferred setup checkpoint: {err}"
);
}
return false;
}
if app.onboarding != crate::tui::app::OnboardingState::None
|| app.view_stack.top_kind() == Some(ModalKind::SetupWizard)
|| !crate::tui::setup::should_open_update_checkpoint(app, config)
{
return false;
}
let _ = app.next_draft_gen();
app.view_stack
.push(crate::tui::setup::SetupWizardView::new_for_app(app, config));
true
}
pub(crate) fn is_explore_offline_shortcut(key: &KeyEvent) -> bool {
matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O'))
&& key.modifiers.contains(KeyModifiers::CONTROL)
}
pub(crate) fn open_onboarding_theme_picker(app: &mut App) {
if app.onboarding != OnboardingState::Appearance
|| app.view_stack.top_kind() == Some(ModalKind::ThemePicker)
{
return;
}
let original = app.theme_id.name().to_string();
app.view_stack.push_boxed(
crate::tui::theme_picker::ThemePickerView::boxed_with_treatment(
original,
app.ocean_treatment,
app.ui_locale,
app.background_color_override,
),
);
app.needs_redraw = true;
}
pub(crate) fn select_work_sidebar_tasks(
tasks: Vec<TaskSummary>,
session_started_at: chrono::DateTime<chrono::Utc>,
current_session_id: Option<&str>,
) -> Vec<TaskSummary> {
tasks
.into_iter()
.filter(|task| {
let owner_matches_current = current_session_id
.zip(task.owner_session_id.as_deref())
.is_some_and(|(current, owner)| current == owner);
let owned_by_other_session = current_session_id.is_some()
&& task
.owner_session_id
.as_deref()
.is_some_and(|owner| Some(owner) != current_session_id);
if owned_by_other_session {
return false;
}
match task.status {
TaskStatus::Queued | TaskStatus::Running => {
owner_matches_current || task.owner_session_id.is_none()
}
TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Canceled => {
if task.ended_at.is_none() {
return false;
}
owner_matches_current
|| (task.owner_session_id.is_none()
&& task.created_at >= session_started_at
&& task
.ended_at
.is_some_and(|ended_at| ended_at >= session_started_at))
}
}
})
.collect()
}
pub(crate) fn toggle_settings_view(app: &mut App) {
if app.view_stack.contains_kind(ModalKind::Config) {
app.view_stack.pop_through_kind(ModalKind::Config);
} else {
app.view_stack.push(ConfigView::new_for_app(app));
}
app.needs_redraw = true;
}
pub(crate) fn clear_work_inspector_after_pager_close(app: &mut App, was_work_inspector: bool) {
if was_work_inspector && app.view_stack.top_kind() != Some(ModalKind::Pager) {
app.work_surface.opened = None;
}
}
pub(crate) fn hotbar_slot_from_key(app: &App, key: &event::KeyEvent) -> Option<u8> {
let KeyCode::Char(c) = key.code else {
return None;
};
if !('1'..='8').contains(&c) {
return None;
}
let slot = c.to_digit(10).and_then(|digit| u8::try_from(digit).ok())?;
if key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::SUPER)
{
if app.onboarding != OnboardingState::None
|| !app.view_stack.is_empty()
|| app.is_history_search_active()
|| !visible_slash_menu_entries(app, SLASH_MENU_LIMIT).is_empty()
{
return None;
}
return Some(slot);
}
None
}
pub(crate) fn is_permission_cycle_shortcut(key: &KeyEvent) -> bool {
let forbidden = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER;
if key.modifiers.intersects(forbidden) {
return false;
}
matches!(key.code, KeyCode::BackTab)
|| (matches!(key.code, KeyCode::Tab) && key.modifiers.contains(KeyModifiers::SHIFT))
}
pub(crate) async fn cycle_permission_posture(
app: &mut App,
config: &mut Config,
engine_handle: &EngineHandle,
) {
let control = config.approval_policy_control(
app.config_path.as_deref(),
app.config_profile.as_deref(),
&app.workspace,
);
let changed = if control == crate::config::ApprovalPolicyControl::RootConfig {
app.cycle_root_approval_posture()
} else {
app.cycle_approval_posture()
};
if changed {
if control == crate::config::ApprovalPolicyControl::RootConfig {
config.approval_policy = None;
}
sync_mode_update(app, engine_handle).await;
refresh_config_view_if_open(app, "permission_posture");
}
}
pub(crate) async fn open_onboarding_provider_picker(
app: &mut App,
config: &Config,
engine_handle: &EngineHandle,
focus_current_route: bool,
) {
if app.onboarding != OnboardingState::Provider
|| app.view_stack.top_kind() == Some(ModalKind::ProviderPicker)
{
return;
}
let runtime_status = query_provider_runtime_status(engine_handle).await;
app.view_stack.push(
crate::tui::provider_picker::ProviderPickerView::new_for_onboarding(
app.api_provider,
focus_current_route.then_some(app.onboarding_provider),
config,
runtime_status,
)
.with_locale(app.ui_locale)
.with_provider_health(&app.provider_health),
);
app.needs_redraw = true;
}
pub(crate) fn open_text_pager(app: &mut App, title: String, content: String) {
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
app.view_stack.push(PagerView::from_text(
title,
&content,
width.saturating_sub(2),
));
}
pub(crate) fn open_context_inspector(app: &mut App) {
app.view_stack.push(ContextInspectorView::new(app));
}
pub(crate) fn open_external_url(url: &str) -> Result<()> {
crate::utils::open_url(url)
}
pub(crate) fn refresh_live_transcript_overlay(app: &mut App) {
let Some(mut overlay) = app.view_stack.pop() else {
return;
};
if let Some(typed) = overlay.as_any_mut().downcast_mut::<LiveTranscriptOverlay>() {
typed.refresh_from_app(app);
}
app.view_stack.push_boxed(overlay);
}
pub(crate) fn refresh_context_inspector_overlay(app: &mut App) {
let Some(mut overlay) = app.view_stack.pop() else {
return;
};
if let Some(typed) = overlay.as_any_mut().downcast_mut::<ContextInspectorView>() {
typed.refresh_from_app(app);
}
app.view_stack.push_boxed(overlay);
}
pub(crate) fn open_backtrack_overlay(app: &mut App) {
let mut overlay = LiveTranscriptOverlay::new();
overlay.refresh_from_app(app);
overlay.set_backtrack_preview(0);
app.view_stack.push(overlay);
app.status_message =
Some("Backtrack: \u{2190}/\u{2192} step Enter rewind Esc cancel".to_string());
app.needs_redraw = true;
}
pub(crate) fn open_live_transcript_overlay(app: &mut App) {
if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
return;
}
let mut overlay = LiveTranscriptOverlay::new();
overlay.refresh_from_app(app);
app.view_stack.push(overlay);
app.status_message = Some("Live transcript: tailing (Esc to close)".to_string());
app.needs_redraw = true;
}
pub(crate) fn toggle_live_transcript_overlay(app: &mut App) {
if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
app.view_stack.pop();
app.needs_redraw = true;
return;
}
open_live_transcript_overlay(app);
}
pub(crate) fn open_model_picker_for_provider(
app: &mut App,
config: &Config,
provider: crate::config::ApiProvider,
) {
if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
app.view_stack
.push(crate::tui::model_picker::ModelPickerView::new(app, config));
}
for ch in provider.display_name().chars() {
let _ = app.view_stack.handle_key(crossterm::event::KeyEvent::new(
KeyCode::Char(ch),
KeyModifiers::NONE,
));
}
app.needs_redraw = true;
}
pub(crate) fn disable_hotbar(app: &mut App, config: &mut Config) {
match crate::config_persistence::persist_hotbar_bindings(app.config_path.as_deref(), &[]) {
Ok(path) => {
config.hotbar = Some(Vec::new());
app.status_message = Some(format!(
"Hotbar hidden (hotbar = [] in {}). Bring it back with `/hotbar on`.",
path.display()
));
}
Err(err) => {
app.status_message = Some(format!("Failed to hide Hotbar: {err}"));
app.add_message(HistoryCell::System {
content: format!("Failed to hide Hotbar: {err}"),
});
}
}
app.needs_redraw = true;
}
pub(crate) fn refresh_config_view_if_open(app: &mut App, focus_key: &str) {
if app.view_stack.top_kind() == Some(ModalKind::Config) {
let filter = app.view_stack.pop().and_then(|mut view| {
view.as_any_mut()
.downcast_mut::<ConfigView>()
.map(|config_view| config_view.filter_query().to_string())
});
let mut config_view = ConfigView::new_for_app(app);
if let Some(filter) = filter {
config_view.restore_filter(filter);
}
config_view.focus_key(focus_key);
app.view_stack.push(config_view);
}
}
pub(crate) fn refresh_skills_manager_if_open(
app: &mut App,
status: Option<String>,
focus: Option<&crate::skills::audit::AuditedSkillId>,
) {
if app.view_stack.top_kind() != Some(ModalKind::SkillsManager) {
return;
}
let Some(mut boxed) = app.view_stack.pop() else {
return;
};
let rebuilt = if let Some(prev) = boxed
.as_any_mut()
.downcast_mut::<crate::tui::views::skills_manager::SkillsManagerView>(
) {
crate::tui::views::skills_manager::SkillsManagerView::rebuild_preserving(
app, prev, status, focus,
)
} else {
crate::tui::views::skills_manager::SkillsManagerView::new(app)
};
app.view_stack.push(rebuilt);
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn push_approval_request_view(
app: &mut App,
id: &str,
tool_name: &str,
description: &str,
tool_input: &serde_json::Value,
approval_key: &str,
intent_summary: Option<&str>,
default_selection: crate::config::ApprovalDefaultSelection,
) {
let request = ApprovalRequest::new_with_intent(
id,
tool_name,
description,
tool_input,
approval_key,
intent_summary,
&app.workspace,
);
app.view_stack
.push(ApprovalView::new_with_default_selection(
request,
app.ui_locale,
default_selection,
));
}
pub(crate) fn update_backtrack_overlay_selection(app: &mut App, selected_idx: usize) {
if app.view_stack.top_kind() != Some(ModalKind::LiveTranscript) {
return;
}
let Some(mut overlay) = app.view_stack.pop() else {
return;
};
if let Some(typed) = overlay.as_any_mut().downcast_mut::<LiveTranscriptOverlay>() {
typed.set_backtrack_preview(selected_idx);
}
app.view_stack.push_boxed(overlay);
app.needs_redraw = true;
}
pub(crate) fn backtrack_api_cut_index(api_messages: &[Message], depth: usize) -> Option<usize> {
let mut user_seen = 0usize;
for (idx, msg) in api_messages.iter().enumerate().rev() {
let yields_user = history_cells_from_message(msg)
.iter()
.any(|cell| matches!(cell, HistoryCell::User { .. }));
if yields_user {
if user_seen == depth {
return Some(idx);
}
user_seen += 1;
}
}
None
}
pub(crate) fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool {
let line_meta = app.viewport.transcript_cache.line_meta();
if line_meta.is_empty() {
return false;
}
let top = app
.viewport
.last_transcript_top
.min(line_meta.len().saturating_sub(1));
let current_cell = line_meta
.get(top)
.and_then(crate::tui::scrolling::TranscriptLineMeta::cell_line)
.map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index));
let mut scan_indices = Vec::new();
match direction {
SearchDirection::Forward => {
scan_indices.extend((top.saturating_add(1))..line_meta.len());
}
SearchDirection::Backward => {
scan_indices.extend((0..top).rev());
}
}
for idx in scan_indices {
let Some((cell_index, _)) = line_meta[idx].cell_line() else {
continue;
};
let cell_index = app.original_cell_index_for_rendered(cell_index);
if current_cell.is_some_and(|current| current == cell_index) {
continue;
}
if !matches!(app.history.get(cell_index), Some(HistoryCell::Tool(_))) {
continue;
}
if let Some(anchor) = TranscriptScroll::anchor_for(line_meta, idx) {
app.viewport.transcript_scroll = anchor;
app.viewport.pending_scroll_delta = 0;
app.needs_redraw = true;
return true;
}
}
false
}
pub(crate) fn open_pager_for_selection(app: &mut App) -> bool {
let Some(text) = selection_to_text(app) else {
return false;
};
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
let pager = PagerView::from_text("Selection", &text, width.saturating_sub(2));
app.view_stack.push(pager);
true
}
pub(crate) fn open_pager_for_last_message(app: &mut App) -> bool {
let Some(cell) = app.history.last() else {
return false;
};
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
let text = history_cell_to_text(cell, width);
let pager = PagerView::from_text("Message", &text, width.saturating_sub(2));
app.view_stack.push(pager);
true
}
#[cfg(test)]
pub(crate) fn open_thinking_pager(app: &mut App) -> bool {
open_reasoning_detail_pager(app)
}