use anyhow::Result;
use regex::Regex;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use chrono::Utc;
use crate::agent::runloop::slash_commands::handle_slash_command as process_slash_command;
use crate::agent::runloop::unified::turn::session::interaction_loop::{
InteractionLoopContext, InteractionOutcome, InteractionState,
};
use crate::agent::runloop::unified::turn::session::slash_commands::{
self, SlashCommandContext, SlashCommandControl,
};
use vtcode_core::hooks::SessionEndReason;
use vtcode_core::scheduler::{ScheduleSpec, SessionLanguageCommand, scheduled_tasks_enabled};
use vtcode_core::tools::file_ops::is_image_path;
use vtcode_core::utils::ansi::MessageStyle;
pub(crate) enum CommandProcessingResult {
Outcome(InteractionOutcome),
ContinueLoop,
NotHandled,
UpdateInput(String),
}
pub(crate) async fn handle_input_commands(
input: &str,
ctx: &mut InteractionLoopContext<'_>,
state: &mut InteractionState<'_>,
) -> Result<CommandProcessingResult> {
match input {
"" => return Ok(CommandProcessingResult::ContinueLoop),
"exit" | "quit" => {
ctx.renderer.line(MessageStyle::Info, "✓")?;
return Ok(CommandProcessingResult::Outcome(InteractionOutcome::Exit {
reason: SessionEndReason::Exit,
}));
}
"help" => {
ctx.renderer
.line(MessageStyle::Info, "Commands: exit, help")?;
return Ok(CommandProcessingResult::ContinueLoop);
}
input if input.starts_with('/') && !is_absolute_image_path_input(input) => {
if let Some(command_input) = input.strip_prefix('/') {
let outcome =
match process_slash_command(command_input, ctx.renderer, &ctx.config.workspace)
.await
{
Ok(outcome) => outcome,
Err(err) => {
tracing::error!("slash command parse/dispatch failed: {err:#}");
ctx.renderer.line(
MessageStyle::Error,
&format!("Slash command failed: {}", err),
)?;
return Ok(CommandProcessingResult::ContinueLoop);
}
};
let command_result = match slash_commands::handle_outcome(
outcome,
SlashCommandContext {
thread_id: ctx.thread_id,
active_thread_label: ctx.active_thread_label,
thread_handle: ctx.thread_handle,
renderer: ctx.renderer,
handle: ctx.handle,
session: ctx.session,
header_context: ctx.header_context,
ide_context_bridge: ctx.ide_context_bridge,
config: ctx.config,
vt_cfg: ctx.vt_cfg,
provider_client: ctx.provider_client,
session_bootstrap: ctx.session_bootstrap,
model_picker_state: state.model_picker_state,
palette_state: state.palette_state,
tool_registry: ctx.tool_registry,
conversation_history: ctx.conversation_history,
decision_ledger: ctx.decision_ledger,
context_manager: ctx.context_manager,
session_stats: ctx.session_stats,
input_status_state: state.input_status_state,
tools: ctx.tools,
tool_catalog: ctx.tool_catalog,
async_mcp_manager: ctx.async_mcp_manager.as_ref(),
mcp_panel_state: ctx.mcp_panel_state,
linked_directories: ctx.linked_directories,
ctrl_c_state: ctx.ctrl_c_state,
ctrl_c_notify: ctx.ctrl_c_notify,
full_auto: ctx.full_auto,
loaded_skills: ctx.loaded_skills,
checkpoint_manager: ctx.checkpoint_manager,
lifecycle_hooks: ctx.lifecycle_hooks,
harness_emitter: ctx.harness_emitter,
},
)
.await
{
Ok(result) => result,
Err(err) => {
tracing::error!("slash command execution failed: {err:#}");
ctx.renderer.line(
MessageStyle::Error,
&format!("Slash command failed: {}", err),
)?;
return Ok(CommandProcessingResult::ContinueLoop);
}
};
match command_result {
SlashCommandControl::SubmitPrompt(prompt) => {
return Ok(CommandProcessingResult::UpdateInput(prompt));
}
SlashCommandControl::ReplaceInput(content) => {
ctx.handle.set_input(content);
return Ok(CommandProcessingResult::ContinueLoop);
}
SlashCommandControl::Continue => {
return Ok(CommandProcessingResult::ContinueLoop);
}
SlashCommandControl::BreakWithReason(reason) => {
return Ok(CommandProcessingResult::Outcome(InteractionOutcome::Exit {
reason,
}));
}
}
}
}
_ => {}
}
if scheduler_enabled(ctx)
&& let Some(result) = handle_session_language_command(input, ctx).await?
{
return Ok(result);
}
Ok(CommandProcessingResult::NotHandled)
}
async fn handle_session_language_command(
input: &str,
ctx: &mut InteractionLoopContext<'_>,
) -> Result<Option<CommandProcessingResult>> {
let Some(command) =
vtcode_core::scheduler::parse_session_language_command(input, chrono::Local::now())
else {
return Ok(None);
};
match command? {
SessionLanguageCommand::CreateOneShotPrompt { prompt, run_at } => {
let summary = ctx
.tool_registry
.create_session_prompt_task(
None,
prompt,
ScheduleSpec::one_shot(run_at),
Utc::now(),
)
.await?;
ctx.renderer.line(
MessageStyle::Info,
&format!(
"Scheduled session task {} ({}) for {}.",
summary.id,
summary.name,
run_at
.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S")
),
)?;
Ok(Some(CommandProcessingResult::ContinueLoop))
}
SessionLanguageCommand::ListTasks => {
let tasks = ctx.tool_registry.list_session_tasks().await;
if tasks.is_empty() {
ctx.renderer
.line(MessageStyle::Info, "No session scheduled tasks.")?;
return Ok(Some(CommandProcessingResult::ContinueLoop));
}
for task in tasks {
let next_run = task
.next_run_at
.map(|value| {
value
.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
.unwrap_or_else(|| "none".to_string());
let status = task.last_status.unwrap_or_else(|| "never_run".to_string());
ctx.renderer.line(
MessageStyle::Info,
&format!(
"{} {} {} next={} status={}",
task.id, task.name, task.schedule, next_run, status
),
)?;
}
Ok(Some(CommandProcessingResult::ContinueLoop))
}
SessionLanguageCommand::CancelTask { query } => {
let Some(task) = ctx.tool_registry.delete_session_task(&query).await else {
return Ok(None);
};
ctx.renderer.line(
MessageStyle::Info,
&format!(
"Cancelled session scheduled task {} ({}).",
task.id, task.name
),
)?;
Ok(Some(CommandProcessingResult::ContinueLoop))
}
}
}
fn scheduler_enabled(ctx: &InteractionLoopContext<'_>) -> bool {
let enabled = ctx
.vt_cfg
.as_ref()
.map(|cfg| cfg.automation.scheduled_tasks.enabled)
.unwrap_or(false);
scheduled_tasks_enabled(enabled)
}
fn is_absolute_image_path_input(input: &str) -> bool {
let trimmed = input.trim_start();
if let Some(token) = leading_path_token(trimmed) {
let mut candidate = token.as_str();
if let Some(rest) = candidate.strip_prefix("file://") {
candidate = rest;
}
let candidate = unescape_whitespace(candidate);
let candidate = candidate.as_str();
let path = if let Some(rest) = candidate.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
home.join(rest)
} else {
return false;
}
} else if is_windows_absolute_path(candidate) || Path::new(candidate).is_absolute() {
PathBuf::from(candidate)
} else {
return false;
};
if is_image_path(&path) {
return true;
}
}
if matches_absolute_image_path(trimmed) {
return true;
}
trimmed.starts_with('/') && contains_image_extension(trimmed)
}
fn leading_path_token(input: &str) -> Option<String> {
if input.is_empty() {
return None;
}
let mut chars = input.char_indices().peekable();
let first = chars.peek().map(|(_, ch)| *ch)?;
let (start, quote) = if first == '"' || first == '\'' {
chars.next();
(first.len_utf8(), Some(first))
} else {
(0, None)
};
let mut end = input.len();
if let Some(quote) = quote {
for (idx, ch) in chars {
if ch == quote {
end = idx;
break;
}
}
} else {
let mut idx = start;
while idx < input.len() {
let ch = input[idx..].chars().next().unwrap();
if ch.is_ascii_whitespace() {
end = idx;
break;
}
if ch == '\\'
&& let Some(next) = input[idx + ch.len_utf8()..].chars().next()
&& next.is_ascii_whitespace()
{
idx += ch.len_utf8() + next.len_utf8();
continue;
}
idx += ch.len_utf8();
}
if end == input.len() {
end = idx;
}
}
let token = input[start..end]
.trim_matches(|ch: char| matches!(ch, ',' | '.' | ';' | ':' | ')' | ']' | '}' | '!' | '?'));
if token.is_empty() {
None
} else {
Some(token.to_string())
}
}
fn is_windows_absolute_path(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() > 2 && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/')
}
fn unescape_whitespace(token: &str) -> String {
let mut result = String::with_capacity(token.len());
let mut chars = token.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\'
&& let Some(next) = chars.peek()
&& next.is_ascii_whitespace()
{
result.push(*next);
chars.next();
continue;
}
result.push(ch);
}
result
}
static ABSOLUTE_IMAGE_PATH_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?ix)
^\s*['\"]?
(
(?:file://)?
(?:
~/(?:[^\n/]+/)+
| /(?:[^\n/]+/)+
| [A-Za-z]:[\\/](?:[^\n\\\/]+[\\/])+
)
[^\n]*?
\.(?:png|jpe?g|gif|bmp|webp|tiff?|svg)
)"#,
)
.expect("Failed to compile absolute image path regex")
});
fn matches_absolute_image_path(input: &str) -> bool {
ABSOLUTE_IMAGE_PATH_REGEX.is_match(input)
}
fn contains_image_extension(input: &str) -> bool {
let lower = input.to_ascii_lowercase();
[
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif", ".svg",
]
.iter()
.any(|ext| lower.contains(ext))
}
#[cfg(test)]
mod tests {
use super::is_absolute_image_path_input;
#[test]
fn absolute_image_path_is_not_treated_as_slash_command() {
assert!(is_absolute_image_path_input(
"/Users/vinhnguyenxuan/Desktop/Screenshot 2026-02-06 at 3.39.48 PM.png"
));
}
#[test]
fn absolute_image_path_with_text_is_not_treated_as_slash_command() {
assert!(is_absolute_image_path_input(
"/Users/vinhnguyenxuan/Desktop/Screenshot 2026-02-06 at 3.39.48 PM.png can you see"
));
}
#[test]
fn absolute_non_image_path_is_still_slash_command_candidate() {
assert!(!is_absolute_image_path_input(
"/Users/vinhnguyenxuan/Desktop/notes.txt"
));
}
#[test]
fn absolute_image_path_with_unescaped_spaces_is_not_treated_as_slash_command() {
assert!(is_absolute_image_path_input(
"/Users/vinhnguyenxuan/Desktop/Screenshot 2026-02-06 at 4.01.01 PM.png can you see"
));
}
#[test]
fn absolute_image_path_with_narrow_no_break_space_is_not_treated_as_slash_command() {
let input = "/Users/vinhnguyenxuan/Desktop/Screenshot 2026-02-06 at 4.00.44\u{202F}PM.png can you see";
assert!(is_absolute_image_path_input(input));
}
}