diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index fb473154..779e00b9 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,5 +1,5 @@ use std::collections::VecDeque; -use std::time::Instant; +use std::time::{Duration, Instant}; use protocol::{ AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, PodStatus, @@ -107,6 +107,33 @@ impl QueuedInput { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum ActionbarNoticeLevel { + Info, + Warn, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionbarNoticeSource { + Tui, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionbarNotice { + pub text: String, + pub level: ActionbarNoticeLevel, + pub source: ActionbarNoticeSource, + pub expires_at: Instant, +} + +impl ActionbarNotice { + pub fn is_expired(&self, now: Instant) -> bool { + now >= self.expires_at + } +} + pub struct App { pub pod_name: String, pub connected: bool, @@ -134,6 +161,9 @@ pub struct App { pub latest_llm_wait_event: Option, /// Latest memory extract/consolidation lifecycle event for actionbar observability. pub latest_memory_worker_event: Option, + /// Current transient actionbar notice. Notices are local UI state only: + /// they are never appended to transcript/session history or LLM context. + actionbar_notice: Option, /// Normal composer input that is submitted as `Method::Run`. pub input: InputBuffer, /// Separate command-line input. It is never submitted as a user message. @@ -200,6 +230,7 @@ impl App { current_tool: None, latest_llm_wait_event: None, latest_memory_worker_event: None, + actionbar_notice: None, input: InputBuffer::new(), command_input: InputBuffer::new(), input_mode: CommandInputMode::Composer, @@ -472,6 +503,48 @@ impl App { self.queued_inputs.len() } + pub fn flash_actionbar_notice( + &mut self, + text: impl Into, + level: ActionbarNoticeLevel, + source: ActionbarNoticeSource, + duration: Duration, + ) { + self.flash_actionbar_notice_at(text, level, source, Instant::now(), duration); + } + + pub fn flash_actionbar_notice_at( + &mut self, + text: impl Into, + level: ActionbarNoticeLevel, + source: ActionbarNoticeSource, + now: Instant, + duration: Duration, + ) { + self.actionbar_notice = Some(ActionbarNotice { + text: text.into(), + level, + source, + expires_at: now + duration, + }); + } + + pub fn current_actionbar_notice(&self, now: Instant) -> Option<&ActionbarNotice> { + self.actionbar_notice + .as_ref() + .filter(|notice| !notice.is_expired(now)) + } + + pub fn clear_expired_actionbar_notice(&mut self, now: Instant) { + if self + .actionbar_notice + .as_ref() + .is_some_and(|notice| notice.is_expired(now)) + { + self.actionbar_notice = None; + } + } + pub fn next_queued_input_preview(&self) -> Option<&str> { self.queued_inputs.front().map(QueuedInput::preview) } @@ -1754,6 +1827,40 @@ mod llm_wait_event_tests { } } +#[cfg(test)] +mod actionbar_notice_tests { + use super::*; + + #[test] + fn actionbar_notice_expires_from_injected_time_source() { + let mut app = App::new("test".into()); + let now = Instant::now(); + let duration = Duration::from_secs(2); + + app.flash_actionbar_notice_at( + "Pod keeps running", + ActionbarNoticeLevel::Warn, + ActionbarNoticeSource::Tui, + now, + duration, + ); + + let notice = app.current_actionbar_notice(now).expect("notice is active"); + assert_eq!(notice.text, "Pod keeps running"); + assert_eq!(notice.level, ActionbarNoticeLevel::Warn); + assert_eq!(notice.source, ActionbarNoticeSource::Tui); + assert_eq!(notice.expires_at, now + duration); + assert!( + app.current_actionbar_notice(now + duration - Duration::from_millis(1)) + .is_some() + ); + assert!(app.current_actionbar_notice(now + duration).is_none()); + + app.clear_expired_actionbar_notice(now + duration); + assert!(app.current_actionbar_notice(now).is_none()); + } +} + #[cfg(test)] mod completion_flow_tests { use super::*; diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 8346206b..f74acff8 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -40,7 +40,7 @@ use tokio::sync::mpsc; use client::PodClient; -use crate::app::App; +use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::picker::PickerOutcome; use crate::spawn::{SpawnOutcome, SpawnReady}; @@ -1094,7 +1094,12 @@ fn handle_pause_or_quit(app: &mut App) -> Option { return None; } app.quit_confirm = Some(std::time::Instant::now()); - app.push_error("Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running)."); + app.flash_actionbar_notice( + "Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).", + ActionbarNoticeLevel::Warn, + ActionbarNoticeSource::Tui, + CONFIRM_TIMEOUT, + ); None } @@ -1565,6 +1570,34 @@ mod tests { })); } + #[test] + fn ctrl_c_quit_guard_uses_actionbar_notice_without_transcript_alert() { + let mut app = App::new("agent".to_string()); + app.set_pod_status(PodStatus::Idle); + + let method = handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + + assert!(method.is_none()); + assert!(!app.quit); + let notice = app + .current_actionbar_notice(std::time::Instant::now()) + .expect("quit guard notice is active"); + assert!(notice.text.contains("Pod keeps running")); + assert_eq!(notice.level, ActionbarNoticeLevel::Warn); + assert_eq!(notice.source, ActionbarNoticeSource::Tui); + assert!(!has_alert(&app, "Pod keeps running")); + + let method = handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert!(method.is_none()); + assert!(app.quit); + } + #[test] fn ctrl_r_requests_rewind_picker_when_idle_or_paused() { let mut app = App::new("agent".to_string()); diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 1400f5fd..61706e74 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -14,6 +14,8 @@ //! lines, and render the tail that fits the history area. No //! `insert_before` use — the terminal scrollback stays untouched. +use std::time::Instant; + use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::style::{Color, Modifier, Style}; @@ -25,7 +27,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use protocol::{AlertLevel, CompletionEntry, Greeting, PodEvent, Segment}; -use crate::app::{App, CompletionState, alert_source_label, fmt_tokens}; +use crate::app::{ActionbarNoticeLevel, App, CompletionState, alert_source_label, fmt_tokens}; use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState}; use crate::command::CommandCandidate; use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore}; @@ -1334,31 +1336,61 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(Paragraph::new(right_line), area); } -fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) { - let mut left: Vec> = Vec::new(); +fn actionbar_left_item(app: &App, now: Instant) -> Option<(String, Style)> { + // Priority is deliberately actionable UI state first, then transient notices, + // then lower-priority lifecycle status. Right-side scroll/view labels are + // rendered independently below. if app.is_command_mode() { - left.push(Span::styled( - "COMMAND", + return Some(( + "COMMAND".to_string(), Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), )); - } else if app.queued_input_count() > 0 { - left.push(Span::styled( - "Alt-q edit queued Alt-c clear queued", + } + if app.queued_input_count() > 0 { + return Some(( + "Alt-q edit queued Alt-c clear queued".to_string(), Style::default().fg(Color::DarkGray), )); - } else if let Some(llm_event) = app.latest_llm_wait_event.as_deref() { - left.push(Span::styled( + } + if let Some(notice) = app.current_actionbar_notice(now) { + return Some(( + truncate_with_ellipsis(¬ice.text, 96), + actionbar_notice_style(notice.level), + )); + } + if let Some(llm_event) = app.latest_llm_wait_event.as_deref() { + return Some(( truncate_with_ellipsis(llm_event, 96), Style::default().fg(Color::Yellow), )); - } else if let Some(memory_event) = app.latest_memory_worker_event.as_deref() { - left.push(Span::styled( + } + if let Some(memory_event) = app.latest_memory_worker_event.as_deref() { + return Some(( truncate_with_ellipsis(memory_event, 72), Style::default().fg(Color::Blue), )); } + None +} + +fn actionbar_notice_style(level: ActionbarNoticeLevel) -> Style { + match level { + ActionbarNoticeLevel::Info => Style::default().fg(Color::Cyan), + ActionbarNoticeLevel::Warn => Style::default().fg(Color::Yellow), + ActionbarNoticeLevel::Error => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + } +} + +fn draw_actionbar(frame: &mut Frame, app: &mut App, area: Rect) { + let now = Instant::now(); + app.clear_expired_actionbar_notice(now); + + let mut left: Vec> = Vec::new(); + if let Some((text, style)) = actionbar_left_item(app, now) { + left.push(Span::styled(text, style)); + } let mut right: Vec> = Vec::new(); if !app.scroll.follow_tail { @@ -1569,8 +1601,9 @@ fn format_pod_event(event: &PodEvent) -> String { #[cfg(test)] mod tests { use super::*; - use crate::app::App; + use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use protocol::PodStatus; + use std::time::{Duration, Instant}; #[test] fn queue_status_text_includes_count_and_preview() { @@ -1593,4 +1626,59 @@ mod tests { assert_eq!(queue_status_text(&app), None); } + + #[test] + fn actionbar_notice_priority_sits_below_actionable_hints_and_above_lifecycle_status() { + let mut app = App::new("test".into()); + let now = Instant::now(); + app.latest_llm_wait_event = Some("retrying LLM request".into()); + app.latest_memory_worker_event = Some("memory extract running".into()); + app.flash_actionbar_notice_at( + "Pod keeps running. Press Ctrl-C again to exit TUI.", + ActionbarNoticeLevel::Warn, + ActionbarNoticeSource::Tui, + now, + Duration::from_secs(3), + ); + + assert_eq!( + actionbar_left_item(&app, now).map(|(text, _)| text), + Some("Pod keeps running. Press Ctrl-C again to exit TUI.".into()) + ); + + app.set_pod_status(PodStatus::Running); + for c in "queued turn".chars() { + app.insert_char(c); + } + assert!(app.submit_input().is_none()); + assert_eq!( + actionbar_left_item(&app, now).map(|(text, _)| text), + Some("Alt-q edit queued Alt-c clear queued".into()) + ); + + app.enter_command_mode(); + assert_eq!( + actionbar_left_item(&app, now).map(|(text, _)| text), + Some("COMMAND".into()) + ); + } + + #[test] + fn expired_actionbar_notice_is_skipped_for_lifecycle_status() { + let mut app = App::new("test".into()); + let now = Instant::now(); + app.latest_llm_wait_event = Some("retrying LLM request".into()); + app.flash_actionbar_notice_at( + "expired", + ActionbarNoticeLevel::Info, + ActionbarNoticeSource::Tui, + now, + Duration::from_secs(1), + ); + + assert_eq!( + actionbar_left_item(&app, now + Duration::from_secs(1)).map(|(text, _)| text), + Some("retrying LLM request".into()) + ); + } }