merge: actionbar transient notices

This commit is contained in:
Keisuke Hirata 2026-05-29 12:57:13 +09:00
commit 18eef116e6
No known key found for this signature in database
3 changed files with 244 additions and 16 deletions

View File

@ -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::*;

View File

@ -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());

View File

@ -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(&notice.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())
);
}
}