tui: add transient actionbar notices
This commit is contained in:
parent
36c24a4c7e
commit
0c32f75df8
|
|
@ -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<String>,
|
||||
/// Latest memory extract/consolidation lifecycle event for actionbar observability.
|
||||
pub latest_memory_worker_event: Option<String>,
|
||||
/// Current transient actionbar notice. Notices are local UI state only:
|
||||
/// they are never appended to transcript/session history or LLM context.
|
||||
actionbar_notice: Option<ActionbarNotice>,
|
||||
/// 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<String>,
|
||||
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<String>,
|
||||
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::*;
|
||||
|
|
|
|||
|
|
@ -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<Method> {
|
|||
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());
|
||||
|
|
|
|||
|
|
@ -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<Span<'static>> = 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<Span<'static>> = Vec::new();
|
||||
if let Some((text, style)) = actionbar_left_item(app, now) {
|
||||
left.push(Span::styled(text, style));
|
||||
}
|
||||
|
||||
let mut right: Vec<Span<'static>> = 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user