use std::borrow::Cow;
use std::io::{self, Write as _};
use crossterm::style::{Color as CtColor, ContentStyle};
use crate::tty::caps::{protocol_mode, LineCaps};
use crate::tty::theme::{self, Role, Theme};
use crate::tty::{LineWriter, Sink, Terminal};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Level {
Info,
Ok,
Warn,
Loud,
Dim,
Thinking,
Narration,
Debug,
}
impl Level {
fn style(self, theme: &Theme) -> Option<ContentStyle> {
let color = match self {
Self::Info => return None,
Self::Thinking => return Some(theme.style(Role::Thinking)),
Self::Narration => return Some(theme.style(Role::Narration)),
Self::Ok => CtColor::DarkGreen,
Self::Warn => CtColor::DarkYellow,
Self::Loud => CtColor::Red,
Self::Dim | Self::Debug => CtColor::DarkGrey,
};
Some(ContentStyle {
foreground_color: Some(color),
..ContentStyle::default()
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Notice<'a> {
pub level: Level,
pub glyph: &'a str,
pub gap: usize,
pub text: Cow<'a, str>,
}
impl<'a> Notice<'a> {
pub fn new(level: Level, glyph: &'a str, text: impl Into<Cow<'a, str>>) -> Self {
Self {
level,
glyph,
gap: 1,
text: text.into(),
}
}
#[must_use]
pub fn gap(mut self, gap: usize) -> Self {
self.gap = gap;
self
}
pub fn line(&self) -> String {
if self.glyph.is_empty() {
return self.text.to_string();
}
format!("{}{}{}", self.glyph, " ".repeat(self.gap), self.text)
}
pub fn emit(&self, caps: LineCaps, sink: Sink, color: bool) {
if protocol_mode() || !caps.can_own() {
return;
}
Terminal::emit_line(sink, self.writer(color));
}
pub fn diagnostic(
&self,
caps: LineCaps,
color: bool,
mut stderr: impl io::Write,
) -> io::Result<()> {
if !protocol_mode() && caps.can_own() {
self.emit(caps, Sink::Stderr, color);
Ok(())
} else {
writeln!(stderr, "{}", self.line())?;
stderr.flush()
}
}
pub(crate) fn writer(&self, color: bool) -> impl FnOnce(&mut LineWriter<'_>) -> io::Result<()> {
let line = self.styled_line(color, &theme::active());
move |w| w.write_all(line.as_bytes())
}
fn styled_line(&self, color: bool, theme: &Theme) -> String {
let line = self.line();
match color.then(|| self.level.style(theme)).flatten() {
Some(style) => format!("{}{line}\x1b[0m\n", theme::ansi_style(style)),
None => format!("{line}\n"),
}
}
}
#[cfg(test)]
mod tests {
use super::{Level, Notice};
#[test]
fn thinking_notice_uses_full_independent_theme_style_and_resets_it() {
use crate::tty::theme::{Role, Theme};
use crossterm::style::{Attribute, Color};
let mut theme = Theme::builtin().overlaid(
"test",
&[
(
Role::Thinking,
Color::Rgb {
r: 12,
g: 34,
b: 56,
},
),
(
Role::AgentText,
Color::Rgb {
r: 78,
g: 90,
b: 123,
},
),
],
);
let mut style = theme.style(Role::Thinking);
style.attributes.set(Attribute::Dim);
style.attributes.set(Attribute::Italic);
style.attributes.set(Attribute::Reverse);
theme.set_style(Role::Thinking, style);
let notice = Notice::new(Level::Thinking, "", " first\n second\nThought for 5s");
assert_eq!(
notice.styled_line(true, &theme),
format!("{}{}\x1b[0m\n", theme.ansi(Role::Thinking), notice.line())
);
assert_eq!(
notice.styled_line(false, &theme),
format!("{}\n", notice.line())
);
assert_eq!(theme.ansi(Role::AgentText), "\x1b[38;2;78;90;123m");
}
#[test]
fn line_is_glyph_then_gap_then_text() {
assert_eq!(Notice::new(Level::Warn, "⚠", "boom").line(), "⚠ boom");
assert_eq!(Notice::new(Level::Ok, "✓", "done").gap(2).line(), "✓ done");
}
#[test]
fn an_empty_glyph_contributes_no_gap() {
assert_eq!(
Notice::new(Level::Warn, "", "⚠ already marked").line(),
"⚠ already marked"
);
}
#[test]
fn line_carries_no_ansi() {
for level in [
Level::Info,
Level::Ok,
Level::Warn,
Level::Loud,
Level::Dim,
Level::Thinking,
Level::Narration,
Level::Debug,
] {
assert!(!Notice::new(level, "⚠", "text").line().contains('\u{1b}'));
}
}
#[test]
fn warn_is_the_single_amber() {
use crossterm::style::Color as CtColor;
let theme = crate::tty::theme::Theme::builtin();
assert_eq!(
Level::Warn.style(&theme).unwrap().foreground_color,
Some(CtColor::DarkYellow)
);
assert_eq!(
Level::Info.style(&theme),
None,
"narration keeps the default fg"
);
}
}