zc2 0.0.25

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! Scrollback buffer for the REPL.
//!
//! Phase 3 of the terminal refactor (see `docs/TERMINAL_REFACTOR.md`).
//!
//! Holds the sequence of output entries produced by commands and flattens them
//! into styled [`Line`]s for display. Entries are either a [`RenderBlock`]
//! (text/markdown/error from a command) or pre-styled raw lines (the launch
//! banner). Owns the vertical scroll offset (measured in lines from the bottom).

#![allow(dead_code)]

use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};

use crate::command::RenderBlock;
use crate::tui::render::markdown;
use crate::tui::theme::Theme;

enum Entry {
    Block(RenderBlock),
    /// Pre-rendered, fully-styled lines (e.g. the banner).
    Raw(Vec<Line<'static>>),
    /// Live command output, appended line-by-line as a subprocess streams.
    Stream(String),
}

/// Max retained scrollback entries; oldest dropped beyond this.
const MAX_ENTRIES: usize = 1000;

/// A growable list of rendered output entries plus a scroll position.
#[derive(Default)]
pub struct Scrollback {
    entries: Vec<Entry>,
    /// Lines scrolled up from the bottom. 0 == pinned to latest output.
    scroll: u16,
}

impl Scrollback {
    pub fn new() -> Self {
        Scrollback::default()
    }

    /// Drop oldest entries beyond `MAX_ENTRIES` so a long session can't grow
    /// the scrollback unboundedly. Call after any append that adds an entry.
    fn evict_if_needed(&mut self) {
        while self.entries.len() > MAX_ENTRIES {
            self.entries.remove(0);
        }
    }

    pub fn push(&mut self, block: RenderBlock) {
        self.entries.push(Entry::Block(block));
        self.evict_if_needed();
        self.scroll = 0;
    }

    /// Push pre-styled lines (used for the launch banner).
    pub fn push_raw(&mut self, lines: Vec<Line<'static>>) {
        self.entries.push(Entry::Raw(lines));
        self.evict_if_needed();
        self.scroll = 0;
    }

    /// Echo the user's prompt input as a block (Claude-Code-style `❯` prefix).
    pub fn push_prompt_echo(&mut self, input: &str) {
        self.entries
            .push(Entry::Block(RenderBlock::Text(format!("{}", input))));
        self.evict_if_needed();
    }

    /// Start a new live-output region for a streaming command.
    pub fn begin_stream(&mut self) {
        self.entries.push(Entry::Stream(String::new()));
        self.evict_if_needed();
        self.scroll = 0;
    }

    /// Append one line to the current stream region (creating one if needed).
    pub fn stream_line(&mut self, line: &str) {
        match self.entries.last_mut() {
            Some(Entry::Stream(buf)) => {
                if !buf.is_empty() {
                    buf.push('\n');
                }
                buf.push_str(line);
            }
            _ => {
                self.entries.push(Entry::Stream(line.to_string()));
                self.evict_if_needed();
            }
        }
        self.scroll = 0;
    }

    pub fn clear(&mut self) {
        self.entries.clear();
        self.scroll = 0;
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Number of output entries currently buffered.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    pub fn scroll_up(&mut self, n: u16) {
        self.scroll = self.scroll.saturating_add(n);
    }

    pub fn scroll_down(&mut self, n: u16) {
        self.scroll = self.scroll.saturating_sub(n);
    }

    pub fn scroll_to_bottom(&mut self) {
        self.scroll = 0;
    }

    /// Flatten all entries into styled lines.
    pub fn to_lines(&self, theme: &Theme) -> Vec<Line<'static>> {
        let mut lines: Vec<Line<'static>> = Vec::new();
        for entry in &self.entries {
            match entry {
                Entry::Raw(raw) => lines.extend(raw.iter().cloned()),
                Entry::Stream(s) => {
                    if s.is_empty() {
                        continue;
                    }
                    let style = Style::default().fg(theme.fg);
                    for line in s.split('\n') {
                        lines.push(Line::from(Span::styled(line.to_string(), style)));
                    }
                }
                Entry::Block(RenderBlock::Text(s)) => {
                    let style = if s.starts_with("") {
                        Style::default()
                            .fg(theme.accent)
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(theme.fg)
                    };
                    for line in s.split('\n') {
                        lines.push(Line::from(Span::styled(line.to_string(), style)));
                    }
                }
                Entry::Block(RenderBlock::Markdown(md)) => {
                    let text: Text<'static> = markdown::render(md, theme);
                    lines.extend(text.lines);
                }
                Entry::Block(RenderBlock::Error(e)) => {
                    let style = Style::default().fg(theme.error);
                    for line in e.split('\n') {
                        lines.push(Line::from(Span::styled(format!("{}", line), style)));
                    }
                }
            }
            lines.push(Line::from(""));
        }
        lines
    }

    /// Compute the scroll offset (top line index) for a viewport `height`,
    /// clamping `self.scroll` so we never scroll past the start.
    pub fn offset_for(&self, total_lines: usize, height: u16) -> u16 {
        let height = height as usize;
        if total_lines <= height {
            return 0;
        }
        let max_top = (total_lines - height) as u16;
        max_top.saturating_sub(self.scroll)
    }
}

#[cfg(test)]
mod cap_tests {
    use super::*;

    #[test]
    fn scrollback_is_capped() {
        let mut sb = Scrollback::new();
        for i in 0..(MAX_ENTRIES + 100) {
            sb.push(RenderBlock::Text(format!("line {}", i)));
        }
        assert!(
            sb.entries.len() <= MAX_ENTRIES,
            "scrollback must be capped at MAX_ENTRIES, got {}",
            sb.entries.len()
        );
        assert_eq!(sb.entries.len(), MAX_ENTRIES);
    }
}