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
47 changes: 43 additions & 4 deletions crates/cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,12 @@ pub struct App {
pub chord_state: ChordState,
pub chord_label: String,
pub status: Option<(String, Instant)>,
/// Persistent "update available" advisory, sourced from the upgrade cache.
/// Unlike `status`, it is never auto-cleared on tick — it stays in the
/// modeline until you upgrade (after which `cached_update_notice` returns
/// None). Rendered right-aligned at the far edge of the modeline, so it
/// coexists with a transient `status` message shown inline on the left.
pub update_notice: Option<String>,
pub last_diff: Option<String>,
pub should_quit: bool,
pub connected: bool,
Expand Down Expand Up @@ -1535,6 +1541,7 @@ pub async fn run_with_socket(socket: std::path::PathBuf) -> Result<()> {
chord_state: ChordState::default(),
chord_label: String::new(),
status: None,
update_notice: None,
last_diff: None,
should_quit: false,
connected: true,
Expand Down Expand Up @@ -1631,10 +1638,10 @@ pub async fn run_with_socket(socket: std::path::PathBuf) -> Result<()> {

// One-line "update available" notice, sourced from an on-disk cache so it
// never blocks startup (a stale cache refreshes in the background for the
// next launch). Opt out with AGENTD_NO_UPDATE_CHECK=1.
if let Some(notice) = crate::upgrade::cached_update_notice() {
app.set_status(notice);
}
// next launch). Held in a dedicated field so it persists in the modeline
// until the user upgrades, rather than expiring after a few seconds like a
// transient status. Opt out with AGENTD_NO_UPDATE_CHECK=1.
app.update_notice = crate::upgrade::cached_update_notice();

if app.selected_needs_hydration() {
if let Some(id) = app.selection.session_id() {
Expand Down Expand Up @@ -7512,6 +7519,7 @@ mod tests {
chord_state: ChordState::default(),
chord_label: String::new(),
status: None,
update_notice: None,
last_diff: None,
should_quit: false,
connected: true,
Expand Down Expand Up @@ -8320,6 +8328,37 @@ mod tests {
server.abort();
}

#[tokio::test]
async fn update_notice_renders_right_aligned_in_modeline() {
let (mut app, _dir, server) = empty_app().await;
app.update_notice = Some("↑ agentd 9.9.9 · agent upgrade".to_string());
let backend = ratatui::backend::TestBackend::new(120, 36);
let mut terminal = ratatui::Terminal::new(backend).expect("terminal");

terminal
.draw(|f| crate::ui::render(f, &mut app))
.expect("draw");

let screen = rendered_text(terminal.backend().buffer());
let modeline = screen
.lines()
.find(|l| l.contains("↑ agentd 9.9.9 · agent upgrade"))
.expect("update notice should be on screen");

// Right-aligned: only padding follows it to the right edge.
assert!(
modeline.trim_end().ends_with("agent upgrade"),
"notice should sit at the right edge:\n{modeline}"
);
// ...and it lives in the right half, not inline on the left.
let col = modeline.find('↑').expect("arrow present");
assert!(
col > 60,
"notice should start in the right half (byte col {col}):\n{modeline}"
);
server.abort();
}

#[tokio::test]
async fn empty_state_shortcut_clicks_dispatch_actions() {
let (mut app, _dir, server) = empty_app().await;
Expand Down
23 changes: 23 additions & 0 deletions crates/cli/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5213,6 +5213,29 @@ fn render_modeline(f: &mut Frame, area: Rect, app: &App) {
.fg(app.theme.modeline_fg),
);
f.render_widget(para, area);

// Persistent "update available" notice, right-aligned at the far edge of
// the status bar so it stays visible until upgrade without crowding the
// left-aligned modeline (transient `status` messages still render inline).
if let Some(notice) = app.update_notice.as_deref() {
use unicode_width::UnicodeWidthStr;
let text = format!(" {notice} ");
let w = UnicodeWidthStr::width(text.as_str()) as u16;
if w > 0 && w < area.width {
let nrect = Rect {
x: area.x + area.width - w,
y: area.y,
width: w,
height: area.height,
};
let np = Paragraph::new(text).style(
Style::default()
.bg(app.theme.modeline_bg)
.fg(app.theme.modeline_fg),
);
f.render_widget(np, nrect);
}
}
}

/// Compute how many rows the minibuffer footer occupies this frame.
Expand Down
6 changes: 2 additions & 4 deletions crates/cli/src/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,7 @@ pub fn cached_update_notice() -> Option<String> {
/// The notice string for `latest` vs `current`, or `None` when `latest` is
/// not strictly newer (or unparseable).
fn notice_for(latest: &str, current: &str) -> Option<String> {
is_newer(latest, current).then(|| {
format!("agentd {latest} available (you have {current}) — run `agent upgrade`")
})
is_newer(latest, current).then(|| format!("↑ agentd {latest} · agent upgrade"))
}

// --- `agent upgrade` -------------------------------------------------------
Expand Down Expand Up @@ -259,7 +257,7 @@ mod tests {
fn notice_only_when_a_newer_version_exists() {
assert_eq!(
notice_for("0.2.0", "0.1.0").as_deref(),
Some("agentd 0.2.0 available (you have 0.1.0) — run `agent upgrade`")
Some("agentd 0.2.0 · agent upgrade")
);
assert_eq!(notice_for("0.1.0", "0.1.0"), None);
assert_eq!(notice_for("0.1.0", "0.2.0"), None);
Expand Down
Loading