Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/cortex-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ audio = ["dep:rodio"]
# Cortex TUI core (the TUI engine)
cortex-core = { path = "../cortex-core" }

# Auto-update system
cortex-update = { path = "../cortex-update" }

# Centralized TUI components
cortex-tui-components = { path = "../cortex-tui-components" }

Expand Down
2 changes: 1 addition & 1 deletion src/cortex-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ mod types;
pub use approval::{ApprovalState, PendingToolResult};
pub use autocomplete::{AutocompleteItem, AutocompleteState};
pub use session::{ActiveModal, SessionSummary};
pub use state::AppState;
pub use state::{AppState, UpdateStatus};
pub use streaming::StreamingState;
pub use subagent::{
SubagentDisplayStatus, SubagentTaskDisplay, SubagentTodoItem, SubagentTodoStatus,
Expand Down
98 changes: 98 additions & 0 deletions src/cortex-tui/src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ use cortex_core::{
style::ThemeColors,
widgets::{CortexInput, Message},
};
// DownloadProgress and UpdateInfo are used in future download tracking feature
#[allow(unused_imports)]
use cortex_update::{DownloadProgress, UpdateInfo};
use uuid::Uuid;

use crate::permissions::PermissionMode;
Expand All @@ -22,6 +25,59 @@ use super::streaming::StreamingState;
use super::subagent::SubagentTaskDisplay;
use super::types::{AppView, FocusTarget, OperationMode};

/// Status of the auto-update system
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum UpdateStatus {
/// No update check performed yet
#[default]
NotChecked,
/// An update is available
Available {
/// The new version available
version: String,
},
/// Currently downloading the update
Downloading {
/// The version being downloaded
version: String,
/// Download progress percentage (0-100)
progress: u8,
},
/// Download complete, restart required
ReadyToRestart {
/// The version that was downloaded
version: String,
},
}

impl UpdateStatus {
/// Returns true if an update notification should be shown
pub fn should_show_banner(&self) -> bool {
matches!(
self,
UpdateStatus::Available { .. }
| UpdateStatus::Downloading { .. }
| UpdateStatus::ReadyToRestart { .. }
)
}

/// Get the banner text for the current status
pub fn banner_text(&self) -> Option<String> {
match self {
UpdateStatus::Available { version } => {
Some(format!("A new version ({}) is available", version))
}
UpdateStatus::Downloading { progress, .. } => {
Some(format!("Downloading update... {}%", progress))
}
UpdateStatus::ReadyToRestart { .. } => {
Some("You must restart to run the latest version".to_string())
}
_ => None,
}
}
}

/// Main application state
pub struct AppState {
pub view: AppView,
Expand Down Expand Up @@ -172,6 +228,10 @@ pub struct AppState {
pub user_email: Option<String>,
/// Organization name for welcome screen
pub org_name: Option<String>,
/// Current update status for the banner notification
pub update_status: UpdateStatus,
/// Cached update info when an update is available
pub update_info: Option<cortex_update::UpdateInfo>,
}

impl AppState {
Expand Down Expand Up @@ -272,6 +332,8 @@ impl AppState {
user_name: None,
user_email: None,
org_name: None,
update_status: UpdateStatus::default(),
update_info: None,
}
}

Expand Down Expand Up @@ -679,3 +741,39 @@ impl AppState {
self.diff_scroll = (self.diff_scroll + delta).max(0);
}
}

// ============================================================================
// APPSTATE METHODS - Update Status
// ============================================================================

impl AppState {
/// Set the update status
pub fn set_update_status(&mut self, status: UpdateStatus) {
self.update_status = status;
}

/// Set update info when an update is available
pub fn set_update_info(&mut self, info: Option<cortex_update::UpdateInfo>) {
self.update_info = info;
}

/// Check if an update banner should be shown
pub fn should_show_update_banner(&self) -> bool {
self.update_status.should_show_banner()
}

/// Get the update banner text if one should be shown
pub fn get_update_banner_text(&self) -> Option<String> {
self.update_status.banner_text()
}

/// Update download progress
pub fn update_download_progress(&mut self, progress: u8) {
if let UpdateStatus::Downloading { version, .. } = &self.update_status {
self.update_status = UpdateStatus::Downloading {
version: version.clone(),
progress,
};
}
}
}
38 changes: 37 additions & 1 deletion src/cortex-tui/src/runner/app_runner/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use super::auth_status::AuthStatus;
use super::exit_info::{AppExitInfo, ExitReason};
use super::trusted_workspaces::{is_workspace_trusted, mark_workspace_trusted};

use crate::app::AppState;
use crate::app::{AppState, UpdateStatus};
use crate::bridge::SessionBridge;
use crate::providers::ProviderManager;
use crate::runner::event_loop::EventLoop;
Expand All @@ -15,7 +15,9 @@ use anyhow::Result;
use cortex_engine::Config;
use cortex_login::{CredentialsStoreMode, load_auth, logout_with_fallback};
use cortex_protocol::ConversationId;
use cortex_update::UpdateManager;
use std::path::PathBuf;
use std::time::Duration;
use tracing;

// ============================================================================
Expand Down Expand Up @@ -552,6 +554,23 @@ impl AppRunner {
let session_history_task =
tokio::task::spawn_blocking(|| CortexSession::list_recent(50).ok());

// 2. Background update check task - check for new versions without blocking startup
let update_check_task = tokio::spawn(async move {
match UpdateManager::new() {
Ok(manager) => match manager.check_update().await {
Ok(info) => info,
Err(e) => {
tracing::debug!("Update check failed: {}", e);
None
}
},
Err(e) => {
tracing::debug!("Failed to create update manager: {}", e);
None
}
}
});

// 3. Models prefetch and session validation - spawn in background
// We use a channel to receive results and update provider_manager later
let models_and_validation_task = {
Expand Down Expand Up @@ -640,6 +659,23 @@ impl AppRunner {
);
}

// Collect update check result (with short timeout to not block startup)
if let Ok(Ok(Some(info))) =
tokio::time::timeout(Duration::from_secs(3), update_check_task).await
{
tracing::info!(
"Update available: {} -> {}",
info.current_version,
info.latest_version
);
app_state.set_update_status(UpdateStatus::Available {
version: info.latest_version.clone(),
});
app_state.set_update_info(Some(info));
} else {
tracing::debug!("Update check did not complete in time or no update available");
}

// Check validation result (with short timeout - don't block TUI)
// We'll handle models update after event loop is created
let validation_result = tokio::time::timeout(
Expand Down
62 changes: 62 additions & 0 deletions src/cortex-tui/src/views/minimal_session/rendering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -960,3 +960,65 @@ pub fn render_motd_compact(

Paragraph::new(lines).render(text_area, buf);
}

/// Renders an update notification banner above the input box.
/// Shows different states: Available, Downloading (with progress), ReadyToRestart
pub fn render_update_banner(
area: Rect,
buf: &mut Buffer,
colors: &AdaptiveColors,
update_status: &crate::app::UpdateStatus,
) {
use crate::app::UpdateStatus;

if area.is_empty() || area.height < 1 {
return;
}

let (icon, text, style) = match update_status {
UpdateStatus::Available { version } => {
let icon = "↑";
let text = format!(" A new version ({}) is available ", version);
let style = Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD);
(icon, text, style)
}
UpdateStatus::Downloading {
version: _,
progress,
} => {
let icon = "⟳";
let text = format!(" Downloading update... {}% ", progress);
let style = Style::default().fg(colors.warning);
(icon, text, style)
}
UpdateStatus::ReadyToRestart { version: _ } => {
let icon = "✓";
let text = " You must restart to run the latest version ".to_string();
let style = Style::default()
.fg(colors.success)
.add_modifier(Modifier::BOLD);
(icon, text, style)
}
_ => return, // Don't render for other states
};

// Calculate banner width
let banner_width = (icon.len() + text.len() + 2) as u16; // +2 for spacing

// Position at left side of the area with some padding
let x = area.x + 2;
let y = area.y;

// Ensure we don't overflow
if x + banner_width > area.right() {
return;
}

// Render icon
buf.set_string(x, y, icon, style);

// Render text
buf.set_string(x + icon.len() as u16 + 1, y, &text, style);
}
23 changes: 21 additions & 2 deletions src/cortex-tui/src/views/minimal_session/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,8 @@ impl<'a> Widget for MinimalSessionView<'a> {
self.app_state.autocomplete.visible && self.app_state.autocomplete.has_items();
let autocomplete_height: u16 = if autocomplete_visible { 10 } else { 0 };
let status_height: u16 = if is_task_running { 1 } else { 0 };
let show_update_banner = self.app_state.should_show_update_banner();
let update_banner_height: u16 = if show_update_banner { 1 } else { 0 };
let input_height: u16 = 3;
let hints_height: u16 = 1;

Expand All @@ -584,7 +586,12 @@ impl<'a> Widget for MinimalSessionView<'a> {
layout.gap(1);

// Calculate available height for scrollable content (before input/hints)
let bottom_reserved = status_height + input_height + autocomplete_height + hints_height + 2; // +2 for gaps
let bottom_reserved = status_height
+ update_banner_height
+ input_height
+ autocomplete_height
+ hints_height
+ 2; // +2 for gaps
let available_height = area.height.saturating_sub(1 + bottom_reserved); // 1 for top margin

// Render scrollable content area (welcome cards + messages together)
Expand All @@ -608,7 +615,19 @@ impl<'a> Widget for MinimalSessionView<'a> {
next_y += status_height;
}

// 6. Input area - follows status (or content if no status)
// 5.5 Update banner (if applicable) - above input
if show_update_banner {
let banner_area = Rect::new(area.x, next_y, area.width, update_banner_height);
super::rendering::render_update_banner(
banner_area,
buf,
&self.colors,
&self.app_state.update_status,
);
next_y += update_banner_height;
}

// 6. Input area - follows update banner (or status if no banner)
let input_y = next_y;
let input_area = Rect::new(area.x, input_y, area.width, input_height);

Expand Down
3 changes: 3 additions & 0 deletions src/cortex-update/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ flate2 = "1.0"
zip = "2.2"
tar = "0.4"

# Secure random
getrandom = "0.2"

# Self-replacement
self-replace = "1.5"

Expand Down
Loading