use std::io::Write as _;
use std::sync::Arc;
use std::time::Duration;
use crossterm::queue;
use crossterm::style::{Color as CtColor, Print, ResetColor, SetForegroundColor};
use crate::progress::{Commit, Durable, Frame, Note, ProgressSink, Scrollback, TaskId};
use crate::tty::caps::{protocol_mode, LineCaps};
use crate::tty::row::EphemeralRow;
use crate::tty::widgets::{Level, Notice};
use crate::tty::{fit_line, format_spinner, term_cols, Sink, FADE_CT, SPINNER_FRAMES};
const GLYPH_PERIOD: Duration = Duration::from_millis(100);
#[must_use]
pub(crate) fn may_render(caps: LineCaps, protocol: bool) -> bool {
if protocol {
return false;
}
caps.can_own()
}
pub struct TerminalProgressSink {
caps: LineCaps,
sink: Sink,
color: bool,
scrollback: Scrollback,
row: Option<Arc<EphemeralRow>>,
}
impl std::fmt::Debug for TerminalProgressSink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TerminalProgressSink")
.field("caps", &self.caps)
.field("sink", &self.sink)
.field("color", &self.color)
.field("scrollback", &self.scrollback)
.field("owns_row", &self.row.is_some())
.finish()
}
}
impl TerminalProgressSink {
#[must_use]
pub fn new(caps: LineCaps, sink: Sink, color: bool) -> Self {
Self {
caps,
sink,
color,
scrollback: Scrollback::new(),
row: None,
}
}
#[must_use]
pub fn animating(caps: LineCaps, sink: Sink, color: bool) -> Option<Self> {
let row = EphemeralRow::acquire(caps, sink)?;
let mut me = Self::new(caps, sink, color);
me.row = Some(row);
Some(me)
}
pub fn finish(&mut self, trailing: Option<Note>) {
let Some(row) = self.row.take() else {
return;
};
let commit = trailing.and_then(|note| self.scrollback.offer(&Durable::Note(note)));
let allowed = may_render(self.caps, protocol_mode());
let color = self.color;
row.finish(|row| {
if let (Some(commit), true) = (commit, allowed) {
row.emit_line(Self::notice_of(&commit).writer(color));
}
});
}
#[must_use]
fn glyph_index(elapsed: Duration) -> usize {
let periods = elapsed.as_millis() / GLYPH_PERIOD.as_millis();
(periods % SPINNER_FRAMES.len() as u128) as usize
}
#[must_use]
pub fn frame_line(frame: &Frame) -> String {
format_spinner(
Self::glyph_index(frame.elapsed),
frame.elapsed.as_secs_f32(),
&frame.label,
usize::try_from(frame.units).unwrap_or(usize::MAX),
)
}
#[must_use]
pub fn committed(&self) -> &[Commit] {
self.scrollback.committed()
}
#[cfg(test)]
pub(super) fn row(&self) -> Option<&Arc<EphemeralRow>> {
self.row.as_ref()
}
#[must_use]
pub fn line_of(commit: &Commit) -> String {
Self::notice_of(commit).line()
}
fn notice_of(commit: &Commit) -> Notice<'_> {
match commit {
Commit::Started { label } => Notice::new(Level::Info, "▸", label.clone()).gap(2),
Commit::Advanced(m) => {
let text = match m.total {
Some(total) => format!("{}/{total}", m.done),
None => m.done.to_string(),
};
Notice::new(Level::Dim, "·", text).gap(2)
}
Commit::Note(n) => Notice::new(n.level, n.glyph, n.text.clone()).gap(2),
Commit::Finished(outcome) => {
let (level, glyph, text) = match outcome {
crate::progress::Outcome::Completed => (Level::Ok, "✓", "done"),
crate::progress::Outcome::Failed => (Level::Warn, "⚠", "failed"),
crate::progress::Outcome::Cancelled => (Level::Dim, "⧉", "cancelled"),
};
Notice::new(level, glyph, text).gap(2)
}
}
}
}
impl ProgressSink for TerminalProgressSink {
fn frame(&mut self, _task: TaskId, _at_ms: u64, frame: &Frame) {
let Some(row) = self.row.as_ref() else {
return;
};
let fitted = fit_line(&Self::frame_line(frame), term_cols());
let color = self.color;
row.paint(move |w| {
if color {
queue!(
w,
SetForegroundColor(CtColor::DarkGrey),
Print(&fitted.head),
SetForegroundColor(FADE_CT),
Print(&fitted.fade),
Print(fitted.ellipsis),
ResetColor,
)?;
} else {
write!(w, "{}{}{}", fitted.head, fitted.fade, fitted.ellipsis)?;
}
Ok(())
});
}
fn record(&mut self, _task: TaskId, _at_ms: u64, event: &Durable) {
let Some(commit) = self.scrollback.offer(event) else {
return;
};
if !may_render(self.caps, protocol_mode()) {
return;
}
Self::notice_of(&commit).emit(self.caps, self.sink, self.color);
}
}
impl Drop for TerminalProgressSink {
fn drop(&mut self) {
self.finish(None);
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Condvar, Mutex};
use super::*;
use crate::progress::{Measure, Outcome};
use crate::tty::arbiter::Ephemeral;
const TASK: TaskId = "compaction";
fn animating() -> TerminalProgressSink {
TerminalProgressSink::animating(LineCaps::Own, Sink::Stdout, false)
.expect("Own yields the row")
}
fn frame_of(label: &str, ms: u64, units: u64) -> Frame {
Frame::new(label)
.after(Duration::from_millis(ms))
.with_units(units)
}
#[test]
fn the_view_composes_the_row_from_a_frames_facts() {
assert_eq!(
TerminalProgressSink::frame_line(&frame_of("thinking…", 1230, 340)),
"⠹ thinking… 1.2s · 340 chars"
);
assert_eq!(
TerminalProgressSink::frame_line(&frame_of("compressing context…", 500, 0)),
"⠴ compressing context… 0.5s"
);
}
#[test]
fn the_same_frame_renders_the_same_row_however_often_it_is_painted() {
let f = frame_of("thinking…", 1230, 340);
let first = TerminalProgressSink::frame_line(&f);
for _ in 0..50 {
assert_eq!(TerminalProgressSink::frame_line(&f), first);
}
}
#[test]
fn and_the_glyph_cycle_covers_the_whole_frame_set() {
let seen: Vec<usize> = (0..SPINNER_FRAMES.len() as u64)
.map(|i| TerminalProgressSink::glyph_index(Duration::from_millis(i * 100)))
.collect();
assert_eq!(
seen,
(0..SPINNER_FRAMES.len()).collect::<Vec<_>>(),
"one glyph per 100 ms of elapsed time, in order"
);
assert_eq!(
TerminalProgressSink::glyph_index(Duration::from_millis(1000)),
0
);
assert_eq!(
TerminalProgressSink::glyph_index(Duration::from_secs(86_400)),
0,
"a day-long turn indexes a real glyph, not a panic"
);
}
#[test]
fn a_commit_only_renderer_owns_no_row_and_drops_frames() {
let mut s = TerminalProgressSink::new(LineCaps::Own, Sink::Stderr, false);
assert!(s.row.is_none(), "`new` is commit-only");
for t in 0..100 {
s.frame(TASK, t, &frame_of("f", t, t));
}
assert!(s.committed().is_empty());
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn only_one_renderer_may_own_the_row_at_a_time() {
let first = animating();
assert!(
TerminalProgressSink::animating(LineCaps::Own, Sink::Stdout, false).is_none(),
"one row, one writer"
);
drop(first);
assert!(
TerminalProgressSink::animating(LineCaps::Own, Sink::Stdout, false).is_some(),
"the row is free again"
);
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn no_capability_yields_no_animating_renderer() {
assert!(TerminalProgressSink::animating(LineCaps::None, Sink::Stdout, true).is_none());
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn erase_waits_for_an_in_flight_paint_instead_of_racing_it() {
let view = animating();
let row = view.row().expect("the renderer holds the row").clone();
let in_flight = row
.paint_gate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let at_call = Arc::new((Mutex::new(false), Condvar::new()));
let erased = Arc::new(AtomicBool::new(false));
let eraser = {
let (row, at_call, erased) = (row.clone(), at_call.clone(), erased.clone());
std::thread::spawn(move || {
{
let (m, cv) = &*at_call;
*m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = true;
cv.notify_all();
}
Ephemeral::erase(&*row);
erased.store(true, Ordering::SeqCst);
})
};
{
let (m, cv) = &*at_call;
let mut at = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
while !*at {
at = cv
.wait(at)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
}
std::thread::sleep(Duration::from_millis(100));
assert!(
!erased.load(Ordering::SeqCst),
"erase ran while a paint held the gate — that frame's bytes would land AFTER the erase"
);
drop(in_flight);
eraser.join().expect("the erasing thread");
assert!(
erased.load(Ordering::SeqCst),
"erase must proceed once the in-flight paint releases the gate"
);
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn erase_stays_idempotent_and_composes_with_finish() {
let mut view = animating();
let row = view.row().expect("the row").clone();
Ephemeral::erase(&*row);
Ephemeral::erase(&*row);
view.finish(Some(Note {
level: Level::Dim,
glyph: "",
text: " trailing".into(),
}));
Ephemeral::erase(&*row);
assert!(row.is_finished());
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn teardown_is_idempotent_and_unforgettable() {
let mut view = animating();
let row = view.row().expect("the row").clone();
assert!(!row.is_finished());
view.finish(None);
assert!(row.is_finished());
view.finish(None);
assert!(row.is_finished());
drop(view);
assert!(row.is_finished());
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn a_frame_published_after_teardown_does_not_land() {
let mut view = animating();
view.finish(None);
assert!(view.row.is_none(), "teardown takes the row");
view.frame(TASK, 0, &frame_of("late…", 100, 0));
assert!(view.committed().is_empty());
}
#[test]
fn protocol_mode_vetoes_rendering_from_every_capability() {
let cases: &[(LineCaps, bool, bool)] = &[
(LineCaps::Own, false, true),
(LineCaps::Own, true, false),
(LineCaps::None, false, false),
(LineCaps::None, true, false),
];
for &(caps, protocol, want) in cases {
assert_eq!(
may_render(caps, protocol),
want,
"caps={caps:?} protocol={protocol}"
);
}
}
#[test]
fn and_a_real_terminal_outside_protocol_mode_does_render() {
assert!(
may_render(LineCaps::Own, false),
"a real terminal with no protocol guard is the ONE shape that renders — \
if this fails the veto test above is vacuous"
);
}
#[test]
fn a_vetoed_sink_still_records_what_would_have_committed() {
let mut s = TerminalProgressSink::new(LineCaps::None, Sink::Stderr, false);
s.record(
TASK,
0,
&Durable::Started {
label: "compacting".into(),
},
);
s.record(TASK, 1, &Durable::Finished(Outcome::Completed));
assert_eq!(
s.committed().len(),
2,
"the commit rule runs regardless of whether bytes are allowed"
);
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn an_animating_renderer_paints_every_frame_and_commits_none() {
let mut s = animating();
for t in 0..100 {
s.frame(TASK, t, &frame_of("thinking…", t, t));
}
assert!(
s.committed().is_empty(),
"frames committed: {:?}",
s.committed()
);
}
#[test]
fn rendered_lines_carry_no_ansi_and_no_newline() {
let commits = [
Commit::Started {
label: "compacting".into(),
},
Commit::Advanced(Measure::out_of(3, 10)),
Commit::Advanced(Measure::of(7)),
Commit::Note(Note {
level: Level::Warn,
glyph: "⚠",
text: "trimmed".into(),
}),
Commit::Finished(Outcome::Completed),
Commit::Finished(Outcome::Failed),
Commit::Finished(Outcome::Cancelled),
];
for c in &commits {
let line = TerminalProgressSink::line_of(c);
assert!(!line.contains('\u{1b}'), "ANSI in {line:?}");
assert!(!line.contains('\n'), "newline in {line:?}");
assert!(!line.is_empty(), "empty line for {c:?}");
}
}
#[test]
fn an_advance_renders_its_total_only_when_it_has_one() {
assert_eq!(
TerminalProgressSink::line_of(&Commit::Advanced(Measure::out_of(3, 10))),
"· 3/10"
);
assert_eq!(
TerminalProgressSink::line_of(&Commit::Advanced(Measure::of(3))),
"· 3"
);
}
#[test]
fn no_production_path_renders_progress_yet() {
const NEEDLES: &[&str] = &[
concat!("TerminalProgress", "Sink::new("),
concat!("TerminalProgress", "Sink::animating("),
];
for (name, src) in [
(
"newt-core/src/agentic/driver.rs",
include_str!("../agentic/driver.rs"),
),
(
"newt-core/src/agentic/display.rs",
include_str!("../agentic/display.rs"),
),
(
"newt-core/src/agentic/note_sink.rs",
include_str!("../agentic/note_sink.rs"),
),
] {
for needle in NEEDLES {
assert!(
!src.contains(needle),
"{name} constructs the terminal progress renderer via `{needle}` — \
that family has NOT been cut over. Each cutover is its own named PR \
with its own deletion gate (#1864 constraint 5)."
);
}
}
}
#[test]
fn and_the_cutover_scan_is_looking_at_real_sources() {
for (name, src) in [
(
"newt-core/src/agentic/driver.rs",
include_str!("../agentic/driver.rs"),
),
(
"newt-core/src/agentic/display.rs",
include_str!("../agentic/display.rs"),
),
(
"newt-core/src/agentic/note_sink.rs",
include_str!("../agentic/note_sink.rs"),
),
] {
assert!(
src.len() > 1000,
"{name} scanned as {} bytes — the cutover guard would be vacuous",
src.len()
);
}
}
}