use crate::spill_view::{SpillStream, SpillView};
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
const SANITIZE_HISTORY_LINES: usize = 4_096;
const SANITIZE_LINE_CHARS: usize = 4_096;
const MAX_ENTRIES: usize = 64;
const MAX_TOTAL_BYTES: usize = 8 * 1024 * 1024;
const MAX_ENTRY_BYTES: usize = 1024 * 1024;
#[derive(Clone, Debug)]
pub(crate) struct CompletedSpill {
#[cfg_attr(not(feature = "rich-tui"), allow(dead_code))]
id: u64,
lines: Arc<[String]>,
#[cfg_attr(not(feature = "rich-tui"), allow(dead_code))]
dropped_lines: usize,
}
#[cfg_attr(not(feature = "rich-tui"), allow(dead_code))]
impl CompletedSpill {
pub(crate) fn id(&self) -> u64 {
self.id
}
pub(crate) fn lines(&self) -> &[String] {
&self.lines
}
pub(crate) fn total_lines(&self) -> usize {
self.dropped_lines + self.lines.len()
}
pub(crate) fn dropped_lines(&self) -> usize {
self.dropped_lines
}
fn bytes(&self) -> usize {
self.lines.iter().map(String::len).sum()
}
}
#[derive(Debug)]
struct ArchiveState {
next_id: u64,
inspection_available: bool,
bytes: usize,
entries: VecDeque<CompletedSpill>,
}
#[derive(Debug)]
pub(crate) struct CompletedSpillArchive {
state: Mutex<ArchiveState>,
max_entries: usize,
max_total_bytes: usize,
max_entry_bytes: usize,
}
impl Default for CompletedSpillArchive {
fn default() -> Self {
Self::with_limits(MAX_ENTRIES, MAX_TOTAL_BYTES, MAX_ENTRY_BYTES)
}
}
impl CompletedSpillArchive {
fn with_limits(max_entries: usize, max_total_bytes: usize, max_entry_bytes: usize) -> Self {
Self {
state: Mutex::new(ArchiveState {
next_id: 1,
inspection_available: false,
bytes: 0,
entries: VecDeque::new(),
}),
max_entries: max_entries.max(1),
max_total_bytes: max_total_bytes.max(1),
max_entry_bytes: max_entry_bytes.max(1).min(max_total_bytes.max(1)),
}
}
pub(crate) fn retain(&self, output: &str) -> u64 {
let mut view = SpillView::with_limits(80, 1, SANITIZE_HISTORY_LINES, SANITIZE_LINE_CHARS);
view.push_stream_bytes(SpillStream::Stdout, output.as_bytes());
view.finish();
let safe_lines = view.retained_display_lines();
let mut retained = Vec::new();
let mut retained_bytes = 0usize;
let mut omitted_for_bytes = 0usize;
for line in safe_lines.iter().rev() {
let cost = line.len();
if retained_bytes.saturating_add(cost) > self.max_entry_bytes {
omitted_for_bytes = safe_lines.len().saturating_sub(retained.len());
break;
}
retained_bytes += cost;
retained.push(line.clone());
}
retained.reverse();
let mut state = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let id = state.next_id;
state.next_id = state.next_id.saturating_add(1);
let spill = CompletedSpill {
id,
lines: retained.into(),
dropped_lines: view.dropped_line_count() + omitted_for_bytes,
};
state.bytes += spill.bytes();
state.entries.push_back(spill);
while state.entries.len() > self.max_entries || state.bytes > self.max_total_bytes {
if let Some(evicted) = state.entries.pop_front() {
state.bytes = state.bytes.saturating_sub(evicted.bytes());
}
}
id
}
#[cfg_attr(not(feature = "rich-tui"), allow(dead_code))]
pub(crate) fn get(&self, id: u64) -> Option<CompletedSpill> {
self.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.entries
.iter()
.find(|spill| spill.id == id)
.cloned()
}
#[cfg(all(unix, feature = "rich-tui"))]
pub(crate) fn enable_inspection(&self) {
self.state
.lock()
.unwrap_or_else(|p| p.into_inner())
.inspection_available = true;
}
#[cfg(any(test, all(unix, feature = "rich-tui")))]
pub(crate) fn snapshot(&self) -> Vec<CompletedSpill> {
self.state
.lock()
.unwrap_or_else(|p| p.into_inner())
.entries
.iter()
.rev()
.cloned()
.collect()
}
#[cfg_attr(not(feature = "rich-tui"), allow(dead_code))]
pub(crate) fn latest(&self) -> Option<CompletedSpill> {
self.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.entries
.back()
.cloned()
}
}
impl newt_core::agentic::CompletedSpillRenderer for CompletedSpillArchive {
fn recovery_hint(&self, id: u64) -> String {
let available = self
.state
.lock()
.unwrap_or_else(|p| p.into_inner())
.inspection_available;
if available {
format!("F4 inspect · /spill open {id}")
} else {
format!("/spill open {id}")
}
}
fn retain_completed(&self, output: &str) -> Option<u64> {
Some(self.retain(output))
}
fn render_completed(&self, _output: &str, _width: usize, _max_height: usize) -> usize {
0
}
fn is_active(&self) -> bool {
false
}
fn erase(&self) {}
}
#[cfg(test)]
mod tests {
#[cfg(all(unix, feature = "rich-tui"))]
#[test]
fn markers_advertise_inspection_only_after_the_host_installs_it() {
use newt_core::agentic::CompletedSpillRenderer;
let archive = super::CompletedSpillArchive::default();
assert_eq!(archive.recovery_hint(7), "/spill open 7");
archive.enable_inspection();
assert_eq!(archive.recovery_hint(7), "F4 inspect · /spill open 7");
}
#[test]
fn inspection_snapshot_is_newest_first_and_survives_eviction() {
let archive = super::CompletedSpillArchive::with_limits(2, 1000, 500);
let first = archive.retain("first");
let second = archive.retain("second");
let snapshot = archive.snapshot();
assert_eq!(
snapshot
.iter()
.map(super::CompletedSpill::id)
.collect::<Vec<_>>(),
[second, first]
);
archive.retain("third");
assert!(archive.get(first).is_none());
assert_eq!(snapshot[1].lines(), ["first"]);
}
use super::*;
#[test]
fn the_archive_alone_retains_and_declines_to_paint() {
use newt_core::agentic::CompletedSpillRenderer;
let archive = CompletedSpillArchive::default();
assert_eq!(archive.retain_completed("first\n"), Some(1));
assert_eq!(archive.retain_completed("second\n"), Some(2));
assert_eq!(archive.get(1).unwrap().lines(), &["first"]);
assert_eq!(archive.latest().unwrap().id(), 2);
assert_eq!(
archive.render_completed("first\n", 80, 10),
0,
"no rows painted — the surface that uses this cannot take the screen"
);
}
#[test]
fn ids_are_stable_and_latest_tracks_the_newest_result() {
let archive = CompletedSpillArchive::default();
assert_eq!(archive.retain("first\n"), 1);
assert_eq!(archive.retain("second\n"), 2);
assert_eq!(archive.get(1).unwrap().lines(), &["first"]);
assert_eq!(archive.latest().unwrap().id(), 2);
}
#[test]
fn retained_text_is_terminal_safe() {
let archive = CompletedSpillArchive::default();
let id = archive.retain("\x1b[31mred\x1b[0m\nline\twith-control\n");
let spill = archive.get(id).unwrap();
assert_eq!(spill.lines(), &["red", "line with-control"]);
assert!(spill
.lines()
.iter()
.all(|line| !line.chars().any(char::is_control)));
}
#[test]
fn bounds_evict_old_entries_and_keep_the_tail_of_large_results() {
let archive = CompletedSpillArchive::with_limits(2, 64, 20);
let first = archive.retain("first\n");
let second = archive.retain("older-line-that-will-not-fit\ntail\n");
let third = archive.retain("third\n");
assert!(archive.get(first).is_none());
let second = archive.get(second).unwrap();
assert_eq!(second.lines(), &["tail"]);
assert_eq!(second.dropped_lines(), 1);
assert!(archive.get(third).is_some());
}
}