tui: add transient actionbar notices

This commit is contained in:
2026-05-29 12:53:14 +09:00
parent 36c24a4c7e
commit 0c32f75df8
3 changed files with 244 additions and 16 deletions
+108 -1
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::*;