use anyhow::Result;
use std::sync::Arc;
use tokio::sync::Notify;
use tracing::warn;
use vtcode_config::constants::ui;
use vtcode_core::config::loader::VTCodeConfig;
use vtcode_core::config::types::AgentConfig as CoreAgentConfig;
use vtcode_core::llm::provider::{self as uni};
use vtcode_core::utils::ansi::{AnsiRenderer, MessageStyle};
use vtcode_ui::tui::app::{InlineHandle, InlineHeaderContext, InlineListSelection, SubmittedInput};
use crate::agent::runloop::model_picker::{ModelPickerProgress, ModelPickerStart, ModelPickerState};
use crate::agent::runloop::slash_commands::SessionPaletteMode;
use crate::agent::runloop::unified::model_selection::{ModelSwitchCompactionTargets, finalize_model_selection};
use crate::agent::runloop::unified::palettes::{
ActivePalette, LIGHTWEIGHT_MODEL_ACTION_PREFIX, MODE_ACTION_PREFIX, MODEL_TARGET_ACTION_LIGHTWEIGHT,
MODEL_TARGET_ACTION_MAIN, build_lightweight_palette_view, handle_palette_cancel, handle_palette_preview,
handle_palette_selection, show_fork_mode_palette, show_lightweight_model_palette, show_model_target_palette,
show_sessions_palette, show_theme_palette,
};
use crate::agent::runloop::unified::settings_interactive::{
ACTION_CONFIGURE_EDITOR, ACTION_PICK_LIGHTWEIGHT_MODEL, ACTION_PICK_MAIN_MODEL, show_settings_palette,
};
use crate::agent::runloop::unified::ui_interaction::PlaceholderSpinner;
use crate::agent::runloop::unified::url_guard::{
URL_GUARD_TITLE, UrlGuardDecision, UrlGuardPrompt, open_external_url, url_guard_decision,
};
use crate::agent::runloop::welcome::SessionBootstrap;
use super::action::InlineLoopAction;
pub(crate) struct InlineModalProcessor<'a> {
model_picker: ModelPickerCoordinator<'a>,
palette: PaletteCoordinator<'a>,
}
impl<'a> InlineModalProcessor<'a> {
#[expect(clippy::too_many_arguments)]
pub(crate) fn new(
handle: &'a InlineHandle,
header_context: &'a mut InlineHeaderContext,
model_picker_state: &'a mut Option<ModelPickerState>,
palette_state: &'a mut Option<ActivePalette>,
config: &'a mut CoreAgentConfig,
vt_cfg: &'a mut Option<VTCodeConfig>,
provider_client: &'a mut Box<dyn uni::LLMProvider>,
ctrl_c_state: &'a Arc<crate::agent::runloop::unified::state::CtrlCState>,
ctrl_c_notify: &'a Arc<Notify>,
session_bootstrap: &'a SessionBootstrap,
full_auto: bool,
compaction: ModelSwitchCompactionTargets<'a>,
) -> Self {
let model_picker = ModelPickerCoordinator {
state: model_picker_state,
header_context,
config,
vt_cfg,
provider_client,
ctrl_c_state,
ctrl_c_notify,
session_bootstrap,
handle,
full_auto,
compaction,
};
let palette = PaletteCoordinator { state: palette_state, handle };
Self { model_picker, palette }
}
pub(crate) fn active_primary_agent_name(&self) -> Option<String> {
self.model_picker.header_context.primary_agent.clone()
}
pub(crate) fn restore_input_draft(&self, input: SubmittedInput) {
self.model_picker.handle.restore_input_draft(input);
}
pub(crate) fn request_url_guard(&mut self, renderer: &mut AnsiRenderer, url: String) -> Result<InlineLoopAction> {
if matches!(self.palette.state.as_ref(), Some(ActivePalette::UrlGuard { .. })) {
return Ok(InlineLoopAction::Continue);
}
let Some(prompt) = UrlGuardPrompt::parse(url) else {
renderer.line(MessageStyle::Error, "Blocked unsupported external link target.")?;
return Ok(InlineLoopAction::Continue);
};
let previous = self.palette.state.take().map(Box::new);
self.show_url_guard(renderer, &prompt);
*self.palette.state = Some(ActivePalette::UrlGuard { prompt, previous });
Ok(InlineLoopAction::Continue)
}
pub(crate) async fn handle_submit(
&mut self,
renderer: &mut AnsiRenderer,
selection: InlineListSelection,
) -> Result<InlineLoopAction> {
match &selection {
InlineListSelection::PlanApprovalExecute => {
return Ok(InlineLoopAction::PlanApproved { auto_accept: false });
}
InlineListSelection::PlanApprovalAutoAccept => {
return Ok(InlineLoopAction::PlanApproved { auto_accept: true });
}
InlineListSelection::PlanApprovalEditPlan => {
return Ok(InlineLoopAction::PlanEditRequested);
}
_ => {}
}
if let Some(action) = self.handle_url_guard_submit(renderer, &selection)? {
return Ok(action);
}
match self.model_picker.handle_submit(renderer, selection).await? {
ModelPickerOutcome::SkipPalette => Ok(InlineLoopAction::Continue),
ModelPickerOutcome::ForwardToPalette(selection) => {
if let Some(action) = self.handle_settings_submit(&selection) {
return Ok(action);
}
if self.handle_palette_redirect(renderer, &selection).await? {
return Ok(InlineLoopAction::Continue);
}
self.palette
.handle_submit(
renderer,
selection,
self.model_picker.config,
self.model_picker.vt_cfg,
self.model_picker.provider_client.as_ref(),
self.model_picker.session_bootstrap,
self.model_picker.full_auto,
)
.await
}
ModelPickerOutcome::Continue => Ok(InlineLoopAction::Continue),
}
}
async fn open_lightweight_palette(&mut self, renderer: &mut AnsiRenderer) -> Result<()> {
let vt_cfg = self.model_picker.vt_cfg.clone();
let view = {
let loading_spinner = if renderer.supports_inline_ui() {
let spinner =
PlaceholderSpinner::new(self.model_picker.handle, None, None, "Loading lightweight model lists...");
spinner.set_defer_restore(true);
Some(spinner)
} else {
renderer.line(MessageStyle::Info, "Loading lightweight model lists...")?;
None
};
let result = build_lightweight_palette_view(self.model_picker.config, vt_cfg.as_ref()).await;
if let Some(ref s) = loading_spinner {
s.finish_with_restore(true);
}
drop(loading_spinner);
result
};
if show_lightweight_model_palette(renderer, &view, None)? {
*self.palette.state = Some(ActivePalette::LightweightModel { view: Box::new(view) });
}
Ok(())
}
pub(crate) fn handle_cancel(&mut self, renderer: &mut AnsiRenderer) -> Result<InlineLoopAction> {
if self.handle_url_guard_cancel(renderer)? {
return Ok(InlineLoopAction::Continue);
}
if self.model_picker.handle_cancel(renderer)? {
return Ok(InlineLoopAction::Continue);
}
self.palette.handle_cancel(renderer)?;
Ok(InlineLoopAction::Continue)
}
pub(crate) fn handle_preview(
&mut self,
renderer: &mut AnsiRenderer,
selection: InlineListSelection,
) -> Result<InlineLoopAction> {
if matches!(self.palette.state.as_ref(), Some(ActivePalette::UrlGuard { .. })) {
return Ok(InlineLoopAction::Continue);
}
update_header_for_plan_selection(&selection, self.model_picker.header_context, self.model_picker.handle);
self.palette.handle_preview(renderer, selection)
}
fn handle_url_guard_submit(
&mut self,
renderer: &mut AnsiRenderer,
selection: &InlineListSelection,
) -> Result<Option<InlineLoopAction>> {
if !matches!(self.palette.state.as_ref(), Some(ActivePalette::UrlGuard { .. })) {
return Ok(None);
}
let Some(ActivePalette::UrlGuard { prompt, previous }) = self.palette.state.take() else {
return Ok(None);
};
match url_guard_decision(selection) {
Some(UrlGuardDecision::Approve) => {
if let Err(err) = open_external_url(prompt.url()) {
renderer.line(MessageStyle::Error, &format!("Failed to open link: {err}"))?;
}
self.restore_previous_palette(renderer, previous)?;
Ok(Some(InlineLoopAction::Continue))
}
Some(UrlGuardDecision::Deny) => {
self.restore_previous_palette(renderer, previous)?;
Ok(Some(InlineLoopAction::Continue))
}
_ => {
self.show_url_guard(renderer, &prompt);
*self.palette.state = Some(ActivePalette::UrlGuard { prompt, previous });
Ok(Some(InlineLoopAction::Continue))
}
}
}
fn handle_url_guard_cancel(&mut self, renderer: &mut AnsiRenderer) -> Result<bool> {
if !matches!(self.palette.state.as_ref(), Some(ActivePalette::UrlGuard { .. })) {
return Ok(false);
}
let Some(ActivePalette::UrlGuard { previous, .. }) = self.palette.state.take() else {
return Ok(false);
};
self.restore_previous_palette(renderer, previous)?;
Ok(true)
}
fn show_url_guard(&mut self, renderer: &mut AnsiRenderer, prompt: &UrlGuardPrompt) {
renderer.show_list_modal(
URL_GUARD_TITLE,
prompt.lines(),
prompt.items(),
Some(prompt.default_selection()),
None,
);
}
fn restore_previous_palette(
&mut self,
renderer: &mut AnsiRenderer,
previous: Option<Box<ActivePalette>>,
) -> Result<()> {
let Some(previous) = previous else {
return Ok(());
};
self.restore_palette(renderer, *previous)
}
fn restore_palette(&mut self, renderer: &mut AnsiRenderer, palette: ActivePalette) -> Result<()> {
match palette {
ActivePalette::Theme { mode, original_theme_id } => {
if show_theme_palette(renderer, mode)? {
*self.palette.state = Some(ActivePalette::Theme { mode, original_theme_id });
}
}
ActivePalette::Sessions { mode, listings, limit, show_all } => {
if show_sessions_palette(renderer, mode, &listings, limit, show_all)? {
*self.palette.state = Some(ActivePalette::Sessions { mode, listings, limit, show_all });
}
}
ActivePalette::ForkMode { session_id, listings, limit, show_all } => {
if show_fork_mode_palette(renderer, &session_id)? {
*self.palette.state = Some(ActivePalette::ForkMode { session_id, listings, limit, show_all });
}
}
ActivePalette::Settings { state, .. } => {
if show_settings_palette(renderer, state.as_ref(), None)? {
*self.palette.state = Some(ActivePalette::Settings { state, esc_armed: false });
}
}
ActivePalette::ModelTarget => {
if show_model_target_palette(renderer)? {
*self.palette.state = Some(ActivePalette::ModelTarget);
}
}
ActivePalette::LightweightModel { view } => {
if show_lightweight_model_palette(renderer, view.as_ref(), None)? {
*self.palette.state = Some(ActivePalette::LightweightModel { view });
}
}
ActivePalette::UrlGuard { previous, .. } => {
self.restore_previous_palette(renderer, previous)?;
}
ActivePalette::Mode => {}
}
Ok(())
}
async fn handle_palette_redirect(
&mut self,
renderer: &mut AnsiRenderer,
selection: &InlineListSelection,
) -> Result<bool> {
let Some(active) = self.palette.state.as_ref() else {
return Ok(false);
};
match (active, selection) {
(ActivePalette::ModelTarget, InlineListSelection::ConfigAction(action))
if action == MODEL_TARGET_ACTION_MAIN =>
{
self.palette.state.take();
self.model_picker.start_picker(renderer).await?;
Ok(true)
}
(ActivePalette::ModelTarget, InlineListSelection::ConfigAction(action))
if action == MODEL_TARGET_ACTION_LIGHTWEIGHT =>
{
self.palette.state.take();
self.open_lightweight_palette(renderer).await?;
Ok(true)
}
(ActivePalette::Settings { .. }, InlineListSelection::ConfigAction(action))
if action == ACTION_PICK_MAIN_MODEL =>
{
self.palette.state.take();
self.model_picker.start_picker(renderer).await?;
Ok(true)
}
(ActivePalette::Settings { .. }, InlineListSelection::ConfigAction(action))
if action == ACTION_PICK_LIGHTWEIGHT_MODEL =>
{
self.palette.state.take();
self.open_lightweight_palette(renderer).await?;
Ok(true)
}
(ActivePalette::LightweightModel { .. }, InlineListSelection::ConfigAction(action))
if action.starts_with(LIGHTWEIGHT_MODEL_ACTION_PREFIX) =>
{
Ok(false)
}
(ActivePalette::UrlGuard { .. }, _) => Ok(true),
(ActivePalette::Mode, InlineListSelection::ConfigAction(action))
if action.starts_with(MODE_ACTION_PREFIX) =>
{
Ok(false)
}
_ => Ok(false),
}
}
fn handle_settings_submit(&mut self, selection: &InlineListSelection) -> Option<InlineLoopAction> {
match (self.palette.state.as_ref(), selection) {
(Some(ActivePalette::Settings { .. }), InlineListSelection::ConfigAction(action))
if action == ACTION_CONFIGURE_EDITOR =>
{
self.palette.state.take();
Some(InlineLoopAction::Submit("/config tools.editor".into()))
}
_ => None,
}
}
}
struct PaletteCoordinator<'a> {
state: &'a mut Option<ActivePalette>,
handle: &'a InlineHandle,
}
impl<'a> PaletteCoordinator<'a> {
async fn handle_submit(
&mut self,
renderer: &mut AnsiRenderer,
selection: InlineListSelection,
config: &mut CoreAgentConfig,
vt_cfg: &mut Option<VTCodeConfig>,
provider_client: &dyn uni::LLMProvider,
session_bootstrap: &SessionBootstrap,
full_auto: bool,
) -> Result<InlineLoopAction> {
if let Some(active) = self.state.take() {
match (&active, &selection) {
(
ActivePalette::Sessions { mode: SessionPaletteMode::Resume, .. },
InlineListSelection::Session(session_id),
) => {
renderer.line(MessageStyle::Info, &format!("Resuming session: {session_id}"))?;
return Ok(InlineLoopAction::ResumeSession(session_id.clone()));
}
(
ActivePalette::Sessions {
mode: SessionPaletteMode::Fork,
listings,
limit,
show_all,
},
InlineListSelection::Session(session_id),
) => {
show_fork_mode_palette(renderer, session_id)?;
*self.state = Some(ActivePalette::ForkMode {
session_id: session_id.clone(),
listings: listings.clone(),
limit: *limit,
show_all: *show_all,
});
return Ok(InlineLoopAction::Continue);
}
(ActivePalette::ForkMode { .. }, InlineListSelection::SessionForkMode { session_id, summarize }) => {
let mode_label = if *summarize { "summarized" } else { "full-copy" };
renderer.line(MessageStyle::Info, &format!("Forking session: {session_id} ({mode_label})"))?;
return Ok(InlineLoopAction::ForkSession {
session_id: session_id.clone(),
summarize: *summarize,
});
}
(ActivePalette::Mode, InlineListSelection::ConfigAction(action))
if action.starts_with(MODE_ACTION_PREFIX) =>
{
let agent_name = action.strip_prefix(MODE_ACTION_PREFIX).unwrap_or(action);
return Ok(InlineLoopAction::SelectPrimaryAgent { name: Some(agent_name.to_string()) });
}
_ => {}
}
let restore = handle_palette_selection(
active,
selection,
renderer,
self.handle,
config,
vt_cfg,
provider_client,
session_bootstrap,
full_auto,
)
.await?;
if let Some(state) = restore {
*self.state = Some(state);
}
return Ok(InlineLoopAction::Continue);
}
tracing::debug!("Palette selection {:?} dropped -- no active palette (floating overlay event)", selection);
Ok(InlineLoopAction::Continue)
}
fn handle_cancel(&mut self, renderer: &mut AnsiRenderer) -> Result<()> {
if let Some(active) = self.state.take() {
let restore = handle_palette_cancel(active, renderer, self.handle)?;
if let Some(state) = restore {
*self.state = Some(state);
}
}
Ok(())
}
fn handle_preview(
&mut self,
renderer: &mut AnsiRenderer,
selection: InlineListSelection,
) -> Result<InlineLoopAction> {
if let Some(active) = self.state.take() {
let restore = handle_palette_preview(active, selection, renderer, self.handle)?;
if let Some(state) = restore {
*self.state = Some(state);
}
}
Ok(InlineLoopAction::Continue)
}
}
struct ModelPickerCoordinator<'a> {
state: &'a mut Option<ModelPickerState>,
header_context: &'a mut InlineHeaderContext,
config: &'a mut CoreAgentConfig,
vt_cfg: &'a mut Option<VTCodeConfig>,
provider_client: &'a mut Box<dyn uni::LLMProvider>,
ctrl_c_state: &'a Arc<crate::agent::runloop::unified::state::CtrlCState>,
ctrl_c_notify: &'a Arc<Notify>,
session_bootstrap: &'a SessionBootstrap,
handle: &'a InlineHandle,
full_auto: bool,
compaction: ModelSwitchCompactionTargets<'a>,
}
impl<'a> ModelPickerCoordinator<'a> {
async fn start_picker(&mut self, renderer: &mut AnsiRenderer) -> Result<()> {
if self.state.is_some() {
renderer.line(
MessageStyle::Error,
"A model picker session is already active. Complete or cancel it before starting another.",
)?;
return Ok(());
}
let reasoning = self
.vt_cfg
.as_ref()
.map(|cfg| cfg.agent.reasoning_effort)
.unwrap_or(self.config.reasoning_effort);
let service_tier = self.vt_cfg.as_ref().and_then(|cfg| cfg.provider.openai.service_tier);
let workspace_hint = Some(self.config.workspace.clone());
let picker_start = {
let loading_spinner = if renderer.supports_inline_ui() {
let spinner = PlaceholderSpinner::new(self.handle, None, None, "Loading model lists...");
spinner.set_defer_restore(true);
Some(spinner)
} else {
renderer.line(MessageStyle::Info, "Loading model lists...")?;
None
};
let result = ModelPickerState::new(
renderer,
self.vt_cfg.clone(),
reasoning,
service_tier,
workspace_hint,
self.config.provider.clone(),
self.config.model.clone(),
Some(Arc::clone(self.ctrl_c_state)),
Some(Arc::clone(self.ctrl_c_notify)),
)
.await;
if let Some(ref s) = loading_spinner {
s.finish_with_restore(true);
}
drop(loading_spinner);
result
};
match picker_start {
Ok(ModelPickerStart::InProgress(picker)) => {
*self.state = Some(picker);
}
Ok(ModelPickerStart::Completed { state, selection }) => {
if let Err(err) = finalize_model_selection(
renderer,
&state,
selection,
self.config,
self.vt_cfg,
self.provider_client,
self.session_bootstrap,
self.handle,
self.header_context,
self.full_auto,
ModelSwitchCompactionTargets {
history: self.compaction.history,
session_stats: self.compaction.session_stats,
context_manager: self.compaction.context_manager,
session_id: self.compaction.session_id,
thread_id: self.compaction.thread_id,
lifecycle_hooks: self.compaction.lifecycle_hooks,
harness_emitter: self.compaction.harness_emitter,
},
)
.await
{
renderer.line(MessageStyle::Error, &format!("Failed to apply model selection: {err}"))?;
}
}
Err(err) => {
renderer.line(MessageStyle::Error, &format!("Failed to start model picker: {err}"))?;
}
}
Ok(())
}
async fn handle_submit(
&mut self,
renderer: &mut AnsiRenderer,
selection: InlineListSelection,
) -> Result<ModelPickerOutcome> {
let Some(picker) = self.state.as_mut() else {
return Ok(ModelPickerOutcome::ForwardToPalette(selection));
};
let progress = picker.handle_list_selection(renderer, selection)?;
match progress {
ModelPickerProgress::InProgress => {}
ModelPickerProgress::NeedsRefresh => {
picker.refresh_dynamic_models(renderer).await?;
return Ok(ModelPickerOutcome::SkipPalette);
}
ModelPickerProgress::Cancelled => {
*self.state = None;
if !renderer.supports_inline_ui() {
renderer.line(MessageStyle::Info, "Model picker cancelled.")?;
}
}
ModelPickerProgress::Exit => {
*self.state = None;
if !renderer.supports_inline_ui() {
renderer.line(MessageStyle::Info, "Model picker cancelled.")?;
}
}
ModelPickerProgress::Completed(selection) => {
let Some(picker_state) = self.state.take() else {
warn!("Model picker completed but no state was available");
return Ok(ModelPickerOutcome::Continue);
};
if let Err(err) = finalize_model_selection(
renderer,
&picker_state,
selection,
self.config,
self.vt_cfg,
self.provider_client,
self.session_bootstrap,
self.handle,
self.header_context,
self.full_auto,
ModelSwitchCompactionTargets {
history: self.compaction.history,
session_stats: self.compaction.session_stats,
context_manager: self.compaction.context_manager,
session_id: self.compaction.session_id,
thread_id: self.compaction.thread_id,
lifecycle_hooks: self.compaction.lifecycle_hooks,
harness_emitter: self.compaction.harness_emitter,
},
)
.await
{
renderer.line(MessageStyle::Error, &format!("Failed to apply model selection: {err}"))?;
}
}
}
Ok(ModelPickerOutcome::Continue)
}
fn handle_cancel(&mut self, renderer: &mut AnsiRenderer) -> Result<bool> {
if self.state.take().is_some() {
if !renderer.supports_inline_ui() {
renderer.line(MessageStyle::Info, "Model picker cancelled.")?;
}
return Ok(true);
}
Ok(false)
}
}
fn update_header_for_plan_selection(
selection: &InlineListSelection,
header_context: &mut InlineHeaderContext,
handle: &InlineHandle,
) {
let (name, color) = match selection {
InlineListSelection::PlanApprovalExecute | InlineListSelection::PlanApprovalAutoAccept => {
(Some("build".to_string()), Some(ui::AGENT_COLOR_BUILD.to_string()))
}
InlineListSelection::PlanApprovalEditPlan => (Some("plan".to_string()), Some(ui::AGENT_COLOR_PLAN.to_string())),
_ => return,
};
header_context.primary_agent = name.clone();
header_context.primary_agent_color = color.clone();
handle.set_primary_agent(name, color);
}
enum ModelPickerOutcome {
Continue,
ForwardToPalette(InlineListSelection),
SkipPalette,
}