use std::path::Path;
use std::sync::Arc;
use anyhow::Result;
use tokio::sync::Notify;
use vtcode_core::tools::terminal_app::{TerminalAppLauncher, TerminalCommandStrategy};
use vtcode_core::utils::ansi::{AnsiRenderer, MessageStyle};
use vtcode_ui::tui::app::{
InlineHandle, InlineHeaderContext, InlineHeaderHighlight, InlineListItem, InlineListSelection, InlineMessageKind,
InlineSession, ListOverlayRequest, TransientRequest, TransientSubmission,
};
use crate::agent::runloop::unified::overlay_prompt::{OverlayWaitOutcome, show_overlay_and_wait};
use crate::agent::runloop::unified::state::CtrlCState;
use crate::main_helpers::{RelaunchPreference, queue_runtime_relaunch};
use super::{InstallOutcome, StartupUpdateNotice, UpdateExecutionStrategy, Updater};
const UPDATE_AND_RESTART_ACTION: &str = "update:install_and_restart";
const STAY_CURRENT_ACTION: &str = "update:stay_current";
const UPDATE_HIGHLIGHT_TITLE: &str = "Update";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UpdatePromptChoice {
UpdateAndRestart,
StayCurrent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InlineUpdateOutcome {
Continue,
RestartRequested,
}
fn line_count(text: &str) -> usize {
text.lines().count().max(1)
}
fn update_highlight(notice: &StartupUpdateNotice) -> InlineHeaderHighlight {
InlineHeaderHighlight {
title: UPDATE_HIGHLIGHT_TITLE.to_string(),
lines: vec![
format!("v{} -> v{}", notice.current_version, notice.latest_version),
"Run /update install".to_string(),
Updater::release_url(¬ice.latest_version),
],
}
}
pub(crate) fn append_notice_highlight(highlights: &mut Vec<InlineHeaderHighlight>, notice: &StartupUpdateNotice) {
let highlight = update_highlight(notice);
if highlights
.iter()
.any(|existing| existing.title == highlight.title && existing.lines == highlight.lines)
{
return;
}
highlights.push(highlight);
}
fn format_update_banner(notice: &StartupUpdateNotice, _use_unicode: bool) -> String {
let lines = [
format!("Update available! {} -> {}", notice.current_version, notice.latest_version),
"Run /update install to update.".to_string(),
String::new(),
"See full release notes:".to_string(),
Updater::release_url(¬ice.latest_version),
];
lines.join("\n")
}
pub(crate) fn display_update_notice(
handle: &InlineHandle,
header_context: &mut InlineHeaderContext,
use_unicode: bool,
notice: &StartupUpdateNotice,
) {
append_notice_highlight(&mut header_context.highlights, notice);
handle.set_header_context(header_context.clone());
let banner = format_update_banner(notice, use_unicode);
handle.append_pasted_message(InlineMessageKind::Info, banner.clone(), line_count(&banner));
handle.force_redraw();
}
pub(crate) fn display_release_notes(handle: &InlineHandle, version: &semver::Version, highlights: &[String]) {
let text = format_release_notes_text(version, highlights);
handle.append_pasted_message(InlineMessageKind::Info, text.clone(), line_count(&text));
handle.force_redraw();
}
fn format_release_notes_text(version: &semver::Version, highlights: &[String]) -> String {
if highlights.is_empty() {
return format!("VT Code v{}\n\nSee full release notes:\n{}", version, Updater::release_url(version));
}
let mut lines = vec![format!("VT Code v{}", version)];
lines.push(String::new());
for item in highlights {
lines.push(format!(" {item}"));
}
lines.join("\n")
}
fn build_update_prompt_request(notice: &StartupUpdateNotice) -> TransientRequest {
TransientRequest::List(ListOverlayRequest {
title: "Update available".to_string(),
lines: vec![
format!("VT Code {} -> {}", notice.current_version, notice.latest_version),
format!("Release notes: {}", Updater::release_url(¬ice.latest_version)),
],
footer_hint: Some("Choose update and restart, or stay on the current version.".to_string()),
items: vec![
InlineListItem {
title: "Update and restart".to_string(),
subtitle: Some("Run the documented install command and relaunch VT Code.".to_string()),
badge: Some("Recommended".to_string()),
indent: 0,
selection: Some(InlineListSelection::ConfigAction(UPDATE_AND_RESTART_ACTION.to_string())),
search_value: None,
},
InlineListItem {
title: "Stay on current version".to_string(),
subtitle: Some("Dismiss this prompt for the rest of this launch.".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction(STAY_CURRENT_ACTION.to_string())),
search_value: None,
},
],
selected: Some(InlineListSelection::ConfigAction(UPDATE_AND_RESTART_ACTION.to_string())),
search: None,
hotkeys: Vec::new(),
})
}
fn terminal_strategy(strategy: UpdateExecutionStrategy) -> TerminalCommandStrategy {
match strategy {
UpdateExecutionStrategy::Shell => TerminalCommandStrategy::Shell,
UpdateExecutionStrategy::PowerShell => TerminalCommandStrategy::PowerShell,
}
}
fn relaunch_preference(notice: &StartupUpdateNotice) -> RelaunchPreference {
if notice.guidance.action.prefer_path_relaunch {
RelaunchPreference::PreferPathCommand
} else {
RelaunchPreference::PreferOriginalExecutable
}
}
fn map_update_prompt_submission(submission: TransientSubmission) -> Option<UpdatePromptChoice> {
match submission {
TransientSubmission::Selection(InlineListSelection::ConfigAction(action))
if action == UPDATE_AND_RESTART_ACTION =>
{
Some(UpdatePromptChoice::UpdateAndRestart)
}
TransientSubmission::Selection(InlineListSelection::ConfigAction(action)) if action == STAY_CURRENT_ACTION => {
Some(UpdatePromptChoice::StayCurrent)
}
TransientSubmission::Selection(_) => Some(UpdatePromptChoice::StayCurrent),
_ => None,
}
}
pub(crate) async fn run_inline_update_prompt(
renderer: &mut AnsiRenderer,
handle: &InlineHandle,
session: &mut InlineSession,
ctrl_c_state: &Arc<CtrlCState>,
ctrl_c_notify: &Arc<Notify>,
workspace_root: &Path,
notice: &StartupUpdateNotice,
) -> Result<InlineUpdateOutcome> {
let outcome = show_overlay_and_wait(
handle,
session,
build_update_prompt_request(notice),
ctrl_c_state,
ctrl_c_notify,
map_update_prompt_submission,
)
.await?;
match outcome {
OverlayWaitOutcome::Submitted(UpdatePromptChoice::UpdateAndRestart) => {
execute_inline_update(renderer, handle, workspace_root, notice).await
}
OverlayWaitOutcome::Submitted(UpdatePromptChoice::StayCurrent) => {
renderer.line(MessageStyle::Info, "Staying on the current version for this session.")?;
Ok(InlineUpdateOutcome::Continue)
}
OverlayWaitOutcome::Cancelled | OverlayWaitOutcome::Interrupted | OverlayWaitOutcome::Exit => {
Ok(InlineUpdateOutcome::Continue)
}
}
}
pub(crate) async fn execute_inline_update(
renderer: &mut AnsiRenderer,
handle: &InlineHandle,
workspace_root: &Path,
notice: &StartupUpdateNotice,
) -> Result<InlineUpdateOutcome> {
if notice.guidance.source.is_managed() {
return execute_managed_update(renderer, handle, workspace_root, notice);
}
renderer.line(
MessageStyle::Info,
&format!("Updating VT Code {} -> {} ...", notice.current_version, notice.latest_version),
)?;
let updater = Updater::new(¬ice.current_version.to_string())?;
match updater.install_update_with_progress(false, false).await {
Ok(InstallOutcome::Updated(version)) => {
queue_runtime_relaunch(relaunch_preference(notice));
renderer.line(MessageStyle::Info, &format!("Update installed (v{version}). Restarting VT Code..."))?;
Ok(InlineUpdateOutcome::RestartRequested)
}
Ok(InstallOutcome::UpToDate(version)) => {
renderer.line(MessageStyle::Info, &format!("Already on the latest version (v{version})."))?;
Ok(InlineUpdateOutcome::Continue)
}
Err(err) => {
renderer.line(MessageStyle::Error, &format!("Failed to update: {err}"))?;
Ok(InlineUpdateOutcome::Continue)
}
}
}
fn execute_managed_update(
renderer: &mut AnsiRenderer,
handle: &InlineHandle,
workspace_root: &Path,
notice: &StartupUpdateNotice,
) -> Result<InlineUpdateOutcome> {
renderer.line(MessageStyle::Info, &format!("Running update command: {}", notice.guidance.command()))?;
let launcher = TerminalAppLauncher::new(workspace_root.to_path_buf());
handle.suspend_event_loop();
let result = launcher
.run_command_with_strategy(notice.guidance.command(), terminal_strategy(notice.guidance.action.execution));
handle.resume_event_loop();
handle.force_redraw();
match result {
Ok(command_result) if command_result.success => {
queue_runtime_relaunch(relaunch_preference(notice));
renderer.line(MessageStyle::Info, "Update installed. Restarting VT Code...")?;
Ok(InlineUpdateOutcome::RestartRequested)
}
Ok(command_result) => {
renderer.line(
MessageStyle::Error,
&format!("Update command exited with status {}.", command_result.exit_code),
)?;
Ok(InlineUpdateOutcome::Continue)
}
Err(err) => {
renderer.line(MessageStyle::Error, &format!("Failed to run update command: {err}"))?;
Ok(InlineUpdateOutcome::Continue)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use semver::Version;
use tokio::sync::mpsc;
use vtcode_ui::tui::app::{InlineCommand, InlineHandle};
fn sample_notice() -> StartupUpdateNotice {
let updater = Updater::new("0.111.0").expect("updater");
StartupUpdateNotice {
current_version: Version::parse("0.111.0").expect("current"),
latest_version: Version::parse("0.113.0").expect("latest"),
guidance: updater.update_guidance(),
}
}
#[test]
fn banner_uses_release_specific_url() {
let banner = format_update_banner(&sample_notice(), true);
assert!(banner.contains("https://github.com/vinhnx/vtcode/releases/tag/v0.113.0"));
assert!(banner.contains("0.111.0 -> 0.113.0"));
assert!(banner.contains("/update install"));
}
#[test]
fn display_notice_updates_header_and_transcript() {
let (tx, mut rx) = mpsc::unbounded_channel();
let handle = InlineHandle::new_for_tests(tx);
let mut header_context = InlineHeaderContext::default();
display_update_notice(&handle, &mut header_context, true, &sample_notice());
let first = rx.blocking_recv().expect("header command");
let second = rx.blocking_recv().expect("transcript command");
assert!(matches!(first, InlineCommand::SetHeaderContext { .. }));
assert!(matches!(second, InlineCommand::AppendPastedMessage { kind: InlineMessageKind::Info, .. }));
}
#[test]
fn apply_notice_only_adds_one_highlight_per_version() {
let notice = sample_notice();
let mut header_context = InlineHeaderContext::default();
append_notice_highlight(&mut header_context.highlights, ¬ice);
append_notice_highlight(&mut header_context.highlights, ¬ice);
assert_eq!(header_context.highlights.len(), 1);
}
}